fix: harden portal OTA release

This commit is contained in:
2026-09-20 19:44:05 +10:00
parent 7c5b50499e
commit 360b7cff86
20 changed files with 621 additions and 198 deletions
+40 -6
View File
@@ -16,6 +16,9 @@ import serial
_stop_requested = False
IMMEDIATE_EMPTY_READ_SECONDS = 0.01
EMPTY_READ_BACKOFF_INITIAL_SECONDS = 0.01
EMPTY_READ_BACKOFF_MAX_SECONDS = 0.25
def request_stop(_signum, _frame):
@@ -50,7 +53,15 @@ def write_ready(path):
os.close(descriptor)
def capture(port, output, ready_file, should_stop=None):
def capture(
port,
output,
ready_file,
should_stop=None,
deadline_seconds=None,
clock=time.monotonic,
sleep=time.sleep,
):
"""Append serial bytes until terminated by the owning hardware runner."""
global _stop_requested
_stop_requested = False
@@ -64,15 +75,34 @@ def capture(port, output, ready_file, should_stop=None):
):
os.fchmod(output_file.fileno(), 0o600)
write_ready(ready_file)
deadline = None
if deadline_seconds is not None:
deadline = clock() + deadline_seconds
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
while not should_stop():
if deadline is not None and clock() >= deadline:
print("Passive serial capture reached its deadline.", file=sys.stderr)
return 2
read_started = clock()
data = serial_port.read(4096)
if data:
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
output_file.write(data)
continue
# A real serial port blocks for its configured timeout. A
# broken or detached backend can return an empty read
# immediately. Back off only in that pathological case, and
# reset after real data, so the recorder cannot become a
# CPU-bound loop without penalising normal serial timeouts.
if clock() - read_started < IMMEDIATE_EMPTY_READ_SECONDS:
sleep(empty_read_backoff)
empty_read_backoff = min(
empty_read_backoff * 2,
EMPTY_READ_BACKOFF_MAX_SECONDS,
)
else:
# PySerial normally blocks for its configured timeout. A
# broken backend or test double can return immediately;
# never let that turn a detached recorder into a CPU loop.
time.sleep(0.01)
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
except (OSError, serial.SerialException) as error:
print(f"Passive serial capture failed: {error}", file=sys.stderr)
return 1
@@ -84,11 +114,15 @@ def main():
parser.add_argument("--port", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--ready-file", required=True)
parser.add_argument("--deadline-seconds", type=float)
args = parser.parse_args()
if args.deadline_seconds is not None and args.deadline_seconds <= 0:
parser.error("--deadline-seconds must be greater than zero")
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
return capture(args.port, args.output, args.ready_file)
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
if __name__ == "__main__":
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Resource locks shared by WiFiManager and DeviceFramework physical test harnesses.
#
# The protocol deliberately uses a stable, non-secret path and hash input so
# standalone checkouts still coordinate when they use the same host resources.
declare -A WM_HARNESS_LOCK_FDS=()
wm_harness_lock_root() {
local runtime_root
if [[ -n "${ARDUINO_TEST_HARNESS_LOCK_DIR:-}" ]]; then
printf '%s\n' "$ARDUINO_TEST_HARNESS_LOCK_DIR"
return 0
fi
runtime_root="${XDG_RUNTIME_DIR:-}"
if [[ -n "$runtime_root" && -d "$runtime_root" && -w "$runtime_root" ]]; then
printf '%s/arduino-framework-test-harness-locks\n' "$runtime_root"
else
printf '%s/arduino-framework-test-harness-locks\n' "${TMPDIR:-/tmp}"
fi
}
wm_harness_lock_resource() {
local label="$1" resource="$2" lock_root digest lock_file lock_fd
[[ -n "$label" && -n "$resource" ]] || {
echo "A test-harness resource lock needs a label and key." >&2
return 2
}
[[ -n "${WM_HARNESS_LOCK_FDS[$resource]:-}" ]] && return 0
command -v flock >/dev/null 2>&1 || {
echo "flock is required to protect test-harness resources." >&2
return 1
}
command -v sha256sum >/dev/null 2>&1 || {
echo "sha256sum is required to name test-harness resource locks." >&2
return 1
}
lock_root="$(wm_harness_lock_root)"
install -d -m 700 "$lock_root"
digest="$(printf '%s' "$resource" | sha256sum | awk '{print $1}')"
lock_file="$lock_root/${digest}.lock"
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
printf "Cannot start: %s is already in use by another local test-harness process.\n" "$label" >&2
return 1
fi
WM_HARNESS_LOCK_FDS["$resource"]="$lock_fd"
}
wm_harness_lock_serial_port() {
local port="$1" canonical
canonical="$(readlink -f -- "$port" 2>/dev/null || printf '%s' "$port")"
wm_harness_lock_resource "serial port $canonical" "serial-port:$canonical"
}
wm_harness_lock_portal_network() {
wm_harness_lock_resource "the 192.168.4.0/24 portal network" "portal-network:192.168.4.0/24"
}
wm_harness_lock_wifi_adapter() {
local interface="$1"
wm_harness_lock_resource "Wi-Fi adapter $interface" "wifi-adapter:$interface"
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Generated A/B identity used only by WiFiManager's portal OTA test harness.
#
# Keeping the identity in a tiny header lets PlatformIO reuse the one platform
# environment's library objects. The header is ignored and removed on every
# normal test-harness exit; it is never part of a user sketch or release.
declare -A WM_OTA_FIXTURE_LOCK_FDS=()
wm_lock_ota_fixture_environment() {
local environment="$1" lock_root lock_file lock_fd
[[ "$environment" =~ ^[A-Za-z0-9_-]+$ ]] || {
echo "Unsafe OTA fixture environment name: $environment" >&2
return 2
}
[[ -n "${WM_OTA_FIXTURE_LOCK_FDS[$environment]:-}" ]] && return 0
command -v flock >/dev/null 2>&1 || {
echo "flock is required to protect PlatformIO OTA fixture inputs." >&2
return 1
}
lock_root="$root/test/portal-harness/.pio/harness-locks"
install -d -m 700 "$lock_root"
lock_file="$lock_root/${environment}.lock"
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
echo "OTA fixture environment '$environment' is already in use by another local test-harness run." >&2
return 1
fi
WM_OTA_FIXTURE_LOCK_FDS["$environment"]="$lock_fd"
}
wm_ota_fixture_identity_dir() {
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || {
echo "WIFIMANAGER_OTA_IDENTITY_DIR must be set before writing an OTA fixture identity." >&2
return 2
}
printf '%s\n' "$WIFIMANAGER_OTA_IDENTITY_DIR"
}
wm_ota_fixture_identity_header() {
printf '%s/ota_fixture_identity.h\n' "$(wm_ota_fixture_identity_dir)"
}
wm_write_ota_fixture_identity() {
local image="$1" directory header temporary
case "$image" in
A|B) ;;
*)
echo "WiFiManager OTA fixture identity must be A or B." >&2
return 2
;;
esac
directory="$(wm_ota_fixture_identity_dir)" || return
header="$(wm_ota_fixture_identity_header)"
install -d -m 700 "$directory"
temporary="$(mktemp "$directory/ota_fixture_identity.XXXXXX")"
chmod 600 "$temporary"
printf '%s\n' \
'#pragma once' \
'// Generated by the WiFiManager OTA test harness. Do not commit.' \
"#define WM_OTA_FIXTURE_IMAGE \"$image\"" \
> "$temporary"
mv -f -- "$temporary" "$header"
}
wm_remove_ota_fixture_identity() {
local directory header
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || return 0
directory="$(wm_ota_fixture_identity_dir)" || return
header="$(wm_ota_fixture_identity_header)"
rm -f -- "$header"
rmdir -- "$directory" 2>/dev/null || true
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# PlatformIO discovery shared by WiFiManager's local test and hardware runners.
# Non-interactive shells, including an SSH hardware lab session, do not always
# include PlatformIO's standard virtual environment in PATH.
wm_pio_executable() {
local executable
if [[ -n "${WIFIMANAGER_PIO_EXECUTABLE:-}" ]]; then
executable="$WIFIMANAGER_PIO_EXECUTABLE"
else
executable="$(command -v pio 2>/dev/null || true)"
if [[ -z "$executable" && -x "$HOME/.platformio/penv/bin/pio" ]]; then
executable="$HOME/.platformio/penv/bin/pio"
fi
fi
[[ -n "$executable" && -x "$executable" ]] || {
echo "PlatformIO is required; install it or set WIFIMANAGER_PIO_EXECUTABLE." >&2
return 1
}
printf '%s\n' "$executable"
}
wm_pio_available() {
wm_pio_executable >/dev/null
}
wm_pio() {
local executable
executable="$(wm_pio_executable)" || return
"$executable" "$@"
}
+6 -16
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=tools/lib/harness-locks.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
# Shared host-side helpers for the WiFiManager portal hardware test harness.
# They never modify a network interface other than the explicit client adapter.
@@ -121,22 +123,9 @@ wm_default_route_interface() {
}
wm_acquire_hardware_lock() {
# First-party runners deliberately share this lock: a WiFiManager portal
# test and a DeviceFramework hardware test can otherwise serial-flash the
# same selected board concurrently. Keep the WiFiManager override for
# isolated tests and let callers redirect the common lock with TMPDIR.
local lock_file="${WM_HARDWARE_LOCK_FILE:-${TMPDIR:-/tmp}/deviceframework-hardware-test.lock}"
# `ota` deliberately acquires this before it builds firmware, then calls
# the shared portal-start helper which also acquires it. Keep that nested
# path idempotent so the lock covers the whole A/B test harness rather than
# only the serial flash and adapter connection.
[[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0
exec 9>"$lock_file"
flock -n 9 || {
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
return 1
}
WM_HARDWARE_LOCK_HELD=yes
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
# mutually exclusive even when they name different boards or adapters.
wm_harness_lock_portal_network
}
wm_require_client_adapter() {
@@ -162,6 +151,7 @@ wm_require_client_adapter() {
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
return 1
}
wm_harness_lock_wifi_adapter "$interface"
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
return 1
+129 -101
View File
@@ -2,8 +2,17 @@
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
# Portal tests exclusively own their portal network, selected Wi-Fi adapter,
# and named serial port. Avoid consuming every host core unless the developer
# explicitly opts in.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
usage() {
cat <<'USAGE' >&2
@@ -47,8 +56,7 @@ station_env=""
output_dir=""
capture_readme_media="no"
custom_parameter_stress="no"
ota_environment_a=""
ota_environment_b=""
ota_environment_name=""
ota_firmware_a=""
ota_firmware_b=""
ota_browser_prebuilt="no"
@@ -97,37 +105,6 @@ require_ota_serial_capture() {
}
}
wm_pio_executable() {
# Hardware runners are commonly launched over non-interactive SSH, where
# a normal PlatformIO installation is not necessarily on PATH. Honor an
# explicit override first, then PATH, then PlatformIO's standard venv.
# This mirrors DeviceFramework's resolver without changing a user's PATH.
local executable
if [[ -n "${WIFIMANAGER_PIO_EXECUTABLE:-}" ]]; then
executable="$WIFIMANAGER_PIO_EXECUTABLE"
else
executable="$(command -v pio 2>/dev/null || true)"
if [[ -z "$executable" && -x "$HOME/.platformio/penv/bin/pio" ]]; then
executable="$HOME/.platformio/penv/bin/pio"
fi
fi
[[ -n "$executable" && -x "$executable" ]] || {
echo "PlatformIO is required; install it or set WIFIMANAGER_PIO_EXECUTABLE." >&2
return 1
}
printf '%s\n' "$executable"
}
wm_pio_available() {
wm_pio_executable >/dev/null
}
wm_pio() {
local executable
executable="$(wm_pio_executable)" || return
"$executable" "$@"
}
pio_for_portal_environment() {
local environment="$1"
shift
@@ -135,7 +112,7 @@ pio_for_portal_environment() {
case "$environment" in
# Keep the maintained Core 3.3.11 graph in the same persistent cache
# as the ESP32 A/B fixture. It is never cleared by this test harness.
esp32|esp32_ota_*)
esp32|esp32_ota)
;;
*)
wm_pio "$@"
@@ -144,7 +121,7 @@ pio_for_portal_environment() {
esac
local core_dir packages_dir cache_dir
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11}"
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
@@ -164,6 +141,13 @@ prepare_output_dir() {
output_dir="$(cd "$output_dir" && pwd)"
}
prepare_ota_identity_input() {
# The generated A/B marker belongs to this run, never to shared fixture
# source. Keeping it with the private artifacts prevents cross-run drift.
export WIFIMANAGER_OTA_IDENTITY_DIR="$output_dir/ota-fixture-input"
install -d -m 700 "$WIFIMANAGER_OTA_IDENTITY_DIR"
}
validate_run_arguments() {
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -n "$client_interface" ]] || usage
@@ -309,7 +293,7 @@ restore_station_handoff_fixture() {
ssid="$(wm_portal_ssid "$platform")" || return 1
if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \
! wm_verify_portal_route "$client_interface" || \
! wait_for_portal_ready; then
! wait_for_portal_scan_ready; then
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
return 1
fi
@@ -330,14 +314,33 @@ write_readme_media_manifest() {
chmod 600 "$media_dir/manifest.json"
}
wait_for_portal_ready() {
# A portal SSID can be visible before its first background scan has
# completed. Wait for that normal portal-start work before the browser
# test harness asks the device to perform a second, user-initiated refresh.
portal_scan_status() {
curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
}
wait_for_portal_http_ready() {
# The AP can be visible before the portal server has completed startup.
# OTA requires the update UI and its HTTP route; it does not require an
# unrelated background scan to have succeeded.
local attempt response
for attempt in $(seq 1 45); do
response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null || true)"
response="$(portal_scan_status || true)"
if [[ "$response" == *'"state"'* ]]; then
return 0
fi
sleep 1
done
echo 'Portal HTTP API did not become ready within 45 seconds.' >&2
return 1
}
wait_for_portal_scan_ready() {
# The normal portal UI suite exercises the automatic scan and still
# requires a usable result. Keep that coverage separate from HTTP OTA.
local attempt response
for attempt in $(seq 1 45); do
response="$(portal_scan_status || true)"
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
return 0
fi
@@ -351,8 +354,18 @@ wait_for_portal_ready() {
return 1
}
report_initial_portal_scan_status() {
local response
response="$(portal_scan_status || true)"
if [[ -n "$response" ]]; then
printf 'Initial portal scan status: %s\n' "$response"
else
echo 'Initial portal scan status was unavailable after portal startup.' >&2
fi
}
start_portal_session() {
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" ssid reconnect_after_drop="no"
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" require_scan="${5:-yes}" ssid reconnect_after_drop="no"
validate_run_arguments
require_common
wm_acquire_hardware_lock
@@ -362,9 +375,17 @@ start_portal_session() {
# for an idle adapter and then replace a connection it does not own.
wm_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
wm_harness_lock_serial_port "$port"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
if [[ -n "$expected_a_artifact" ]]; then
# B was built most recently. Restore the generated A identity before
# PlatformIO verifies and serial-flashes the immutable A artifact.
wm_write_ota_fixture_identity A
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
if [[ "$erase_before_upload" == "yes" ]]; then
# OTA selection metadata must not survive from a previous fixture run:
# otherwise a bootloader could select a stale app slot instead of A.
@@ -397,26 +418,43 @@ start_portal_session() {
wm_cleanup_created_connection
return 1
fi
if ! wait_for_portal_ready; then
if [[ "$require_scan" == yes ]]; then
wait_for_portal_scan_ready || {
wm_cleanup_created_connection
return 1
}
elif ! wait_for_portal_http_ready; then
wm_cleanup_created_connection
return 1
fi
[[ "$require_scan" == yes ]] || report_initial_portal_scan_status
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}
ota_environment() {
local image="$1"
printf '%s_ota_%s\n' "$platform" "$image"
printf '%s_ota\n' "$platform"
}
ota_marker_field() {
local response="$1" field="$2"
case "$field" in
marker)
sed -n 's/.*"marker"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$response"
;;
freeSketchSpace)
sed -n 's/.*"freeSketchSpace"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<<"$response"
marker|freeSketchSpace)
# The marker endpoint is deliberately fixture-only JSON. Parse it
# as JSON instead of maintaining a second, fragile copy of its
# wire format in a regular expression.
python3 -c '
import json
import sys
try:
value = json.load(sys.stdin)[sys.argv[1]]
except (json.JSONDecodeError, KeyError, TypeError):
raise SystemExit(1)
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise SystemExit(1)
print(value)
' "$field" <<<"$response" 2>/dev/null || true
;;
*)
echo "Unknown OTA marker field: $field" >&2
@@ -515,31 +553,46 @@ ota_assert_firmware_fits() {
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
}
prepare_ota_firmware() {
local source_a source_b capacity
ota_environment_a="$(ota_environment a)"
ota_environment_b="$(ota_environment b)"
# Build and preserve both identities before touching the board. The
# browser mounts B read-only; the A artifact is compared against the
# PlatformIO build used for serial flashing so a later build cannot turn
# the A/B proof into an unrecorded input change.
pio_for_portal_environment "$ota_environment_a" run -d "$root/test/portal-harness" -e "$ota_environment_a"
pio_for_portal_environment "$ota_environment_b" run -d "$root/test/portal-harness" -e "$ota_environment_b"
source_a="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
source_b="$root/test/portal-harness/.pio/build/$ota_environment_b/firmware.bin"
[[ -s "$source_a" && -s "$source_b" ]] || {
echo "PlatformIO did not produce both OTA fixture images." >&2
build_ota_image() {
local image="$1" source artifact
case "$image" in
A)
artifact="$output_dir/${platform}-portal-ota-a.bin"
ota_firmware_a="$artifact"
;;
B)
artifact="$output_dir/${platform}-portal-ota-b.bin"
ota_firmware_b="$artifact"
;;
*)
echo "Unknown portal OTA fixture image: $image" >&2
return 2
;;
esac
wm_write_ota_fixture_identity "$image"
pio_for_portal_environment "$ota_environment_name" run -d "$root/test/portal-harness" -e "$ota_environment_name"
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
[[ -s "$source" ]] || {
echo "PlatformIO did not produce portal OTA image $image." >&2
return 1
}
if cmp -s "$source_a" "$source_b"; then
install -m 600 "$source" "$artifact"
}
prepare_ota_firmware() {
local capacity
ota_environment_name="$(ota_environment)"
wm_lock_ota_fixture_environment "$ota_environment_name"
# Capture immutable A before rebuilding the same environment as B. The
# generated header is the only changed input, so PlatformIO reuses library
# objects while the browser still mounts a distinct B artifact read-only.
build_ota_image A
build_ota_image B
if cmp -s "$ota_firmware_a" "$ota_firmware_b"; then
echo "PlatformIO produced identical OTA A and B images." >&2
return 1
fi
ota_firmware_a="$output_dir/${platform}-portal-ota-a.bin"
ota_firmware_b="$output_dir/${platform}-portal-ota-b.bin"
install -m 600 "$source_a" "$ota_firmware_a"
install -m 600 "$source_b" "$ota_firmware_b"
if [[ "$platform" == "esp32" ]]; then
"$root/tools/check-ota-partitions.sh"
@@ -551,7 +604,7 @@ prepare_ota_firmware() {
ota_assert_a_artifact_matches_build() {
local expected_artifact="$1" source
source="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
[[ -s "$expected_artifact" && -s "$source" ]] || {
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
return 1
@@ -633,6 +686,7 @@ start_ota_serial_capture() {
--port "$port" \
--output "$ota_serial_capture_log" \
--ready-file "$ota_serial_capture_ready" \
--deadline-seconds 600 \
>>"$ota_serial_capture_status" 2>&1 &
ota_serial_capture_pid=$!
@@ -658,30 +712,6 @@ require_ota_serial_capture_running() {
}
}
assert_ota_serial_sequence() {
python3 - "$ota_serial_capture_log" <<'PY'
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8", errors="replace") as source:
lines = source.readlines()
def first_after(marker, start=0):
for index in range(start, len(lines)):
if marker in lines[index]:
return index
return None
a = first_after("WiFiManager portal OTA fixture image: A")
upload_started = first_after("[OTA] Update file:", (a or 0) + 1)
upload_completed = first_after("[OTA] OTA FILE END bytes:", (upload_started or 0) + 1)
b = first_after("WiFiManager portal OTA fixture image: B", (upload_completed or 0) + 1)
if None in (a, upload_started, upload_completed, b):
raise SystemExit("Serial portal-OTA evidence is missing its required A/upload/B ordering.")
PY
}
stop_ota_serial_capture() {
local status=0
[[ -n "$ota_serial_capture_pid" ]] || return 0
@@ -693,10 +723,6 @@ stop_ota_serial_capture() {
fi
ota_serial_capture_pid=""
(( status == 0 )) || return "$status"
if ! assert_ota_serial_sequence; then
echo "Serial portal-OTA evidence did not observe ordered A/upload/B markers: $ota_serial_capture_log" >&2
return 1
fi
}
finish_portal_session() {
@@ -717,6 +743,7 @@ cleanup_portal_session() {
# a recursive EXIT trap must not obscure the original status.
trap - EXIT HUP INT TERM
stop_ota_serial_capture || true
wm_remove_ota_fixture_identity
if ! restore_station_handoff_fixture; then
(( status != 0 )) || status=1
fi
@@ -826,9 +853,10 @@ case "$command_name" in
wm_require_client_adapter "$client_interface" "$takeover"
require_ota_serial_capture
prepare_output_dir
prepare_ota_identity_input
prepare_ota_firmware
prepare_ota_harness_image
start_portal_session "$ota_environment_a" yes "$ota_firmware_a" yes
start_portal_session "$ota_environment_name" yes "$ota_firmware_a" yes no
# A must be visible through the real portal before a B upload can be
# meaningful. On ESP8266 the marker also reports the actual active
+71
View File
@@ -73,6 +73,26 @@ class FakeSerial:
self.close()
class FakeClock:
def __init__(self):
self.value = 0.0
def __call__(self):
return self.value
def advance(self, seconds):
self.value += seconds
class BlockingEmptySerial(FakeSerial):
clock = None
def read(self, _size):
type(self).read_calls += 1
type(self).clock.advance(0.25)
return b""
def load_capture_module():
fake_serial_module = types.ModuleType("serial")
fake_serial_module.Serial = FakeSerial
@@ -126,6 +146,57 @@ def main():
assert ("open", False, False, "/dev/fake") in FakeSerial.events
assert ("close",) in FakeSerial.events
assert FakeSerial.read_calls == 2
# An immediate empty-return backend must back off progressively instead of
# spinning. This takes no real sleep because the sleeper is injected.
FakeSerial.read_calls = 0
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
empty_sleeps = []
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: FakeSerial.read_calls >= 5,
sleep=empty_sleeps.append,
) == 0
assert empty_sleeps == [0.01, 0.02, 0.04, 0.08]
# A normal 250 ms serial timeout is already rate-limited by the device
# driver, so it must not receive an additional backoff sleep.
module.serial.Serial = BlockingEmptySerial
BlockingEmptySerial.events = []
BlockingEmptySerial.read_calls = 0
clock = FakeClock()
BlockingEmptySerial.clock = clock
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
empty_sleeps = []
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: BlockingEmptySerial.read_calls >= 2,
clock=clock,
sleep=empty_sleeps.append,
) == 0
assert empty_sleeps == []
module.serial.Serial = FakeSerial
FakeSerial.read_calls = 0
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: False,
deadline_seconds=0.0,
) == 2
print("WiFiManager passive OTA serial-capture test-harness check passed")
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Check OTA fixture identity and environment locking without PlatformIO or a board.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-identity-test.XXXXXX")"
chmod 700 "$temporary_root"
cleanup() {
rm -rf -- "$temporary_root"
}
trap cleanup EXIT HUP INT TERM
export ARDUINO_TEST_HARNESS_LOCK_DIR="$temporary_root/locks"
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/identity"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_write_ota_fixture_identity A
identity_file="$WIFIMANAGER_OTA_IDENTITY_DIR/ota_fixture_identity.h"
[[ "$(stat -c '%a' "$WIFIMANAGER_OTA_IDENTITY_DIR")" == 700 ]]
[[ "$(stat -c '%a' "$identity_file")" == 600 ]]
rg -Fqx '#define WM_OTA_FIXTURE_IMAGE "A"' "$identity_file"
wm_lock_ota_fixture_environment esp8266_ota
collision_output="$temporary_root/lock-collision.log"
if (
root="$root"
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/other-identity"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
wm_lock_ota_fixture_environment esp8266_ota
) 2>"$collision_output"; then
echo "A second test-harness process unexpectedly acquired the same OTA environment lock." >&2
exit 1
fi
rg -Fqx "OTA fixture environment 'esp8266_ota' is already in use by another local test-harness run." "$collision_output"
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
if (
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
) 2>"$collision_output"; then
echo "A second test-harness process unexpectedly acquired the same resource lock." >&2
exit 1
fi
rg -Fqx 'Cannot start: test resource is already in use by another local test-harness process.' "$collision_output"
wm_remove_ota_fixture_identity
[[ ! -e "$identity_file" ]]
echo "WiFiManager OTA fixture identity test-harness check passed"