diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh index ae6e6ef6c3c..2a8b2d73c04 100755 --- a/config/docker/headless-serve-shutdown/run-signal-case.sh +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -55,17 +55,25 @@ setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0. app_pid=$! app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat") -# The inner shell expands its positional parameters. -# shellcheck disable=SC1083,SC2016 -ready_line=$(timeout --foreground --signal=TERM --kill-after=5s "$startup_timeout_seconds" bash -c ' - tail --pid="$1" -n +1 -F "$2" 2>/dev/null \ - | sed -u -n 's/^[^{]*//p' \ - | jq --unbuffered -Rnc '\''first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))'\'' -' bash "$app_pid" "$stdout_log" || true) -# A readiness event can land as the timeout tears down the tail pipeline. +# jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F +# observer can outlive the timeout and leak into the next signal case. Poll +# finite snapshots instead; each parser invocation has a definite EOF. +read_ready_line() { + sed -u -n 's/^[^{]*//p' "$stdout_log" \ + | jq --unbuffered -Rnc 'first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))' +} + +ready_line='' +startup_deadline=$((SECONDS + startup_timeout_seconds)) +while (( SECONDS < startup_deadline )); do + ready_line=$(read_ready_line) + [[ -n "$ready_line" ]] && break + kill -0 "$app_pid" 2>/dev/null || break + sleep 1 +done +# A readiness event can land as the final poll races the write. if [[ -z "$ready_line" ]]; then - ready_line=$(sed -u -n 's/^[^{]*//p' "$stdout_log" \ - | jq --unbuffered -Rnc 'first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))') + ready_line=$(read_ready_line) fi if [[ -z "$ready_line" ]]; then cat "$stdout_log" "$stderr_log" >&2 diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index feabcaf87d2..b04a613e5d9 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -69,16 +69,17 @@ describe('headless serve shutdown PR gate', () => { expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) }) - it('keeps the readiness parser line-buffered', () => { - expect(signalCase).toContain("| sed -u -n 's/^[^{]*//p'") + it('keeps readiness polling finite and leak-free', () => { + expect(signalCase).toContain('read_ready_line()') + expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'") expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}') + expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))') + expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do') + expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break') expect(signalCase).toContain( - 'timeout --foreground --signal=TERM --kill-after=5s "$startup_timeout_seconds"' + "jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F" ) - expect(signalCase).toContain( - 'A readiness event can land as the timeout tears down the tail pipeline.' - ) - expect(signalCase).toContain('ready_line=$(sed -u -n') + expect(signalCase).not.toContain('tail --pid=') }) it('checks that a serving-electron signal target owns the ready socket', () => {