WIP: test(control): wait until the bridge listens, and reap it when a quit check fails #457

Draft
mooki wants to merge 3 commits from mooki/Longshot:mac/control-quit into main
Member

What this is for: anyone running the game-free suite on Linux or macOS. tests/test_control.py's quit case can hang
for its whole 180 s limit and leave a bridge process running after the suite ends. On this Mac under a loaded suite it
happened in about 1 run of 150. This PR fixes the test fixture; the game's bridge is unchanged. Part of the macOS port
(#420). What is measured: on an Apple M4 Max with macOS 26.7.1, two new runs make the race certain. Without the fix
both hang, 5 of 5 each, with the forked bridge left running; with it, quit slow passes 20 of 20 and quit fail ends
with 1 at once, 20 of 20, nothing left running. tests/test_control.py passes for arm64 and x86_64 (40 PASS lines each).
And the whole suite is compared by test name with main. Not measured: Windows (its run list is unchanged) and Linux
runs.

Depends on #456 (mac/control-sockets): this branch carries its two commits, 3e335a41 and f1f1843d.

Why

The quit case hangs the check, and leaks a process, whenever the bridge it starts is a little late to listen.

Problem

  • quit_case in tests/control_test.c forks: the child runs the bridge, and the parent waits until the socket's file
    exists, then connects once (CHECK(connect(...) == 0)).
  • The bridge makes the file with bind and listens afterwards (transport_open in host/control.c). A connect between
    the two is refused (ECONNREFUSED, measured on macOS; Linux refuses it too [inference: not run here]).
  • CHECK prints and calls exit(1). The child keeps serving and holds the parent's stdout and stderr, so
    test_control.py's subprocess.run(..., capture_output=True, timeout=180) waits for those pipes until its limit.
  • The child is then left running for good, reparented to launchd. Two such bridges from earlier suites were found still
    running on this Mac, with a listening socket and no client.
  • The same happens for any check that fails after the fork. The real clients are not affected: longshot_ctl.py launch
    retries until the bridge answers.

What changed

  • tests/control_test.c:
    • the quit case waits for the child's word on a pipe, written once hos_control_serve has returned (after listen),
      instead of for the socket's file;
    • a failed CHECK ends and reaps the forked bridge before the process exits (check_failed, POSIX only);
    • the parent waits 5 s at most for the child's exit, as the Windows branch already does;
    • two new runs: quit slow (the test build's bridge waits 300 ms between bind and listen) and quit fail (a check
      fails once the child serves; it must end with 1 and leave nothing running).
  • host/control.c: a hook for the test build only, under #ifdef HOS_CONTROL_NO_GAME: hos_control_test_bound,
    called between bind and listen when set. The game never defines HOS_CONTROL_NO_GAME; outside those blocks the file
    is byte for byte the control-sockets PR's.
  • tests/test_control.py: on Linux and macOS it also runs quit slow (must pass) and quit fail (must end with 1
    and the injected failure). Windows' run list is unchanged.

Rebase onto 5e072fc8: test_control.py's run list sits under #58's compile command; #58's command is kept as it is.

Flags and settings

None.

Measurements

main: 5e072fc8c2adadba5ea610148be1c47800ba0cca (2026-11-14)
this PR: mac/control-quit @ 2695995219b146707cd56958824dad6133ec9f6e
Baseline: mac/control-sockets @ f1f1843d, with this PR's hook and two runs but not its fix (main cannot build the
control fixture on macOS at all, so the race cannot be shown there).
Hardware: Apple M4 Max, 16 cores, 64 GiB, macOS 26.7.1 (25G241); Apple clang 21.0.0. No GPU.
Scene: none. Game-free checks.
Method: control_test built as test_control.py builds it on macOS (native arm64), then run <folder> quit slow and
<folder> quit fail with stdout and stderr on pipes: 5 times each without the fix, 20 times each with it, 20 s at most
per run (a run past 20 s is a hang). Then tests/test_control.py --cc clang --arch arm64 and --arch x86_64, and the
control checks through tests/run_all.py on main and on this PR, interleaved, two runs each.
How to reproduce: python -I -B tests/test_control.py --cc clang --arch arm64 (and --arch x86_64), with a short
TMPDIR.

control-sockets @ f1f1843d + hook, no fix this PR @ 26959952
quit slow 5 of 5 hang: the parent exits 1 at connect, its child serves on 20 of 20 pass, median 0.38 s
quit fail 5 of 5 hang: the parent exits 1 at the injected failure, its child serves on 20 of 20 end with 1 at once (median 0.09 s), nothing left running
test_control.py --arch arm64 / x86_64 exit 0, 40 PASS lines / exit 0, the same 40
control-sockets @ f1f1843d + hook, no fix (each of the 5 runs, then still running at 20 s with one live child):
quit slow: FAIL:340 connect(c->fd, (struct sockaddr *)&a, sizeof a) == 0
quit fail: FAIL:493 !"a failure injected once the child serves"

this PR @ 26959952:
quit slow: runs=20 hangs=0 passes=20, pass_seconds_median=0.3835 max=0.7469
quit fail: runs=20 hangs=0, each code 1: FAIL:521 !"a failure injected once the child serves", none left running
PASS: quit: the reply, then a clean exit, the socket removed
PASS: quit: a check failing once the child serves ends the child with it
control checks through tests/run_all.py (run 1 / run 2) main @ 5e072fc8 this PR @ 26959952
tests/test_control.py FAIL / FAIL (control.c does not compile on macOS) pass / pass
tests/test_longshot_ctl.py pass / pass pass / pass
tests/test_oot_state_tap.py FAIL / FAIL FAIL / FAIL (the fixture's vsnprintf macro, which the runner PR repairs)

Earlier, on the old base (ab92a95f, on the port's integration branch, the same fixture and hook): the original
quit case hung 8 times in 1,200 runs under load (7 of 900 arm64, 1 of 300 x86_64), each the refused connect. With the
fix, 0 hangs in 1,900 runs (arm64 and x86_64, idle and loaded). Not rerun here.

Testing

Tested: the above, and the whole suite, tests/run_all.py, 6 at once, on the same Mac:

main @ 5e072fc8:     525 passed, 19 failed, 17 skipped in 177 s (linux, 6 at once)
this PR @ 26959952:  526 passed, 18 failed, 17 skipped in 180 s (linux, 6 at once)
  • By test name, one check changed: tests/test_control.py, FAIL on main (the bridge does not compile on macOS
    there) → pass here in 12.4 s, with the quit runs. No control fixture was left running after the suite. (The
    control-sockets PR's own suite run, without this fix, met the quit race under load and left one running.)
  • The 18 remaining failures also fail on main: test_appimage_package_retry, test_collect_wide,
    test_framegen_resources, test_game_layout, test_low_memory_notice, test_mem_gate, test_mem_gate_children,
    test_mirror_trim, test_module_ids, test_oot_state_tap, test_pad_layout, test_pcache_header_read,
    test_pipeline_cache_file, test_pipeline_cache_save, test_pipeline_play_cache, test_prof_symbols,
    test_program_cache_cleanup, test_program_cache_one_variant. The port's other PRs fix them, apart from three that
    are stale on main on every platform (test_framegen_resources, test_module_ids, test_pad_layout).

Not tested:

  • Linux. It runs the same POSIX quit case and gets the two new runs. Not run here.
  • Windows. Its run list and code path are unchanged; check_failed only prints and exits there, as before.

Needs other testers:

  • Linux: tests/test_control.py. Expect the two new quit PASS lines and no hang (request to open).
  • Windows: tests/test_control.py. Expect the same cases as main (request to open).

Screenshots

Nothing on screen.

Risk and rollback

  • Test code only, apart from the hook, which only the test build (HOS_CONTROL_NO_GAME) compiles.
  • The parent now waits 10 s at most for the child's word. A bridge that takes longer to start fails the check instead of
    hanging it.
  • Rollback: revert the PR.

Reviewer notes

  • Start with quit_case and check_failed in tests/control_test.c.
  • Left out on purpose: the bridge could make its socket's file appear only once it listens (bind under another name,
    listen, rename). That changes the game on every platform, and the real clients already retry.
  • Review only 26959952, this PR's own commit; 3e335a41..f1f1843d is the control-sockets PR.

Issues

See #420.

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main. (Branched from 5e072fc8, on the control-sockets PR it depends on.)
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above). (526 passed,
    17 skipped, and 18 failures that also fail on main, named under Testing.)
  • Tested in the game, or what wasn't is named under Testing. (No game run: a test fixture; named under Testing.)
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). The test
    requests are still to open.
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (For a test change: the new runs hang without the fix and pass with it; the control checks on main and here, two interleaved runs each.)
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • A game-free test added for the fix, failing on main and passing here. (The two new runs hang without the fix and
    pass with it; on main the fixture does not build on macOS.)
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Not built there; the change to
    host/control.c is only in the test build.
**What this is for:** anyone running the game-free suite on Linux or macOS. `tests/test_control.py`'s quit case can hang for its whole 180 s limit and leave a bridge process running after the suite ends. On this Mac under a loaded suite it happened in about 1 run of 150. This PR fixes the test fixture; the game's bridge is unchanged. Part of the macOS port (#420). **What is measured:** on an Apple M4 Max with macOS 26.7.1, two new runs make the race certain. Without the fix both hang, 5 of 5 each, with the forked bridge left running; with it, `quit slow` passes 20 of 20 and `quit fail` ends with 1 at once, 20 of 20, nothing left running. `tests/test_control.py` passes for arm64 and x86_64 (40 PASS lines each). And the whole suite is compared by test name with `main`. **Not measured:** Windows (its run list is unchanged) and Linux runs. Depends on #456 (`mac/control-sockets`): this branch carries its two commits, `3e335a41` and `f1f1843d`. ## Why The quit case hangs the check, and leaks a process, whenever the bridge it starts is a little late to listen. ## Problem - `quit_case` in `tests/control_test.c` forks: the child runs the bridge, and the parent waits until the socket's file exists, then connects once (`CHECK(connect(...) == 0)`). - The bridge makes the file with `bind` and listens afterwards (`transport_open` in `host/control.c`). A connect between the two is refused (`ECONNREFUSED`, measured on macOS; Linux refuses it too [inference: not run here]). - `CHECK` prints and calls `exit(1)`. The child keeps serving and holds the parent's stdout and stderr, so `test_control.py`'s `subprocess.run(..., capture_output=True, timeout=180)` waits for those pipes until its limit. - The child is then left running for good, reparented to launchd. Two such bridges from earlier suites were found still running on this Mac, with a listening socket and no client. - The same happens for any check that fails after the fork. The real clients are not affected: `longshot_ctl.py launch` retries until the bridge answers. ## What changed - **`tests/control_test.c`:** - the quit case waits for the child's word on a pipe, written once `hos_control_serve` has returned (after `listen`), instead of for the socket's file; - a failed `CHECK` ends and reaps the forked bridge before the process exits (`check_failed`, POSIX only); - the parent waits 5 s at most for the child's exit, as the Windows branch already does; - two new runs: `quit slow` (the test build's bridge waits 300 ms between `bind` and `listen`) and `quit fail` (a check fails once the child serves; it must end with 1 and leave nothing running). - **`host/control.c`:** a hook for the test build only, under `#ifdef HOS_CONTROL_NO_GAME`: `hos_control_test_bound`, called between `bind` and `listen` when set. The game never defines `HOS_CONTROL_NO_GAME`; outside those blocks the file is byte for byte the control-sockets PR's. - **`tests/test_control.py`:** on Linux and macOS it also runs `quit slow` (must pass) and `quit fail` (must end with 1 and the injected failure). Windows' run list is unchanged. **Rebase onto `5e072fc8`:** `test_control.py`'s run list sits under #58's compile command; #58's command is kept as it is. ## Flags and settings None. ## Measurements main: `5e072fc8c2adadba5ea610148be1c47800ba0cca` (2026-11-14) this PR: `mac/control-quit` @ `2695995219b146707cd56958824dad6133ec9f6e` Baseline: `mac/control-sockets` @ `f1f1843d`, with this PR's hook and two runs but not its fix (`main` cannot build the control fixture on macOS at all, so the race cannot be shown there). Hardware: Apple M4 Max, 16 cores, 64 GiB, macOS 26.7.1 (25G241); Apple clang 21.0.0. No GPU. Scene: none. Game-free checks. Method: `control_test` built as `test_control.py` builds it on macOS (native arm64), then run `<folder> quit slow` and `<folder> quit fail` with stdout and stderr on pipes: 5 times each without the fix, 20 times each with it, 20 s at most per run (a run past 20 s is a hang). Then `tests/test_control.py --cc clang --arch arm64` and `--arch x86_64`, and the control checks through `tests/run_all.py` on `main` and on this PR, interleaved, two runs each. How to reproduce: `python -I -B tests/test_control.py --cc clang --arch arm64` (and `--arch x86_64`), with a short `TMPDIR`. | | control-sockets @ f1f1843d + hook, no fix | this PR @ 26959952 | |---|---|---| | `quit slow` | 5 of 5 hang: the parent exits 1 at `connect`, its child serves on | 20 of 20 pass, median 0.38 s | | `quit fail` | 5 of 5 hang: the parent exits 1 at the injected failure, its child serves on | 20 of 20 end with 1 at once (median 0.09 s), nothing left running | | `test_control.py --arch arm64` / `x86_64` | | exit 0, 40 PASS lines / exit 0, the same 40 | ``` control-sockets @ f1f1843d + hook, no fix (each of the 5 runs, then still running at 20 s with one live child): quit slow: FAIL:340 connect(c->fd, (struct sockaddr *)&a, sizeof a) == 0 quit fail: FAIL:493 !"a failure injected once the child serves" this PR @ 26959952: quit slow: runs=20 hangs=0 passes=20, pass_seconds_median=0.3835 max=0.7469 quit fail: runs=20 hangs=0, each code 1: FAIL:521 !"a failure injected once the child serves", none left running PASS: quit: the reply, then a clean exit, the socket removed PASS: quit: a check failing once the child serves ends the child with it ``` | control checks through `tests/run_all.py` (run 1 / run 2) | main @ 5e072fc8 | this PR @ 26959952 | |---|---|---| | `tests/test_control.py` | FAIL / FAIL (`control.c` does not compile on macOS) | pass / pass | | `tests/test_longshot_ctl.py` | pass / pass | pass / pass | | `tests/test_oot_state_tap.py` | FAIL / FAIL | FAIL / FAIL (the fixture's `vsnprintf` macro, which the runner PR repairs) | **Earlier, on the old base** (`ab92a95f`, on the port's integration branch, the same fixture and hook): the original quit case hung 8 times in 1,200 runs under load (7 of 900 arm64, 1 of 300 x86_64), each the refused `connect`. With the fix, 0 hangs in 1,900 runs (arm64 and x86_64, idle and loaded). Not rerun here. ## Testing **Tested:** the above, and the whole suite, `tests/run_all.py`, 6 at once, on the same Mac: ``` main @ 5e072fc8: 525 passed, 19 failed, 17 skipped in 177 s (linux, 6 at once) this PR @ 26959952: 526 passed, 18 failed, 17 skipped in 180 s (linux, 6 at once) ``` - **By test name, one check changed:** `tests/test_control.py`, FAIL on `main` (the bridge does not compile on macOS there) → pass here in 12.4 s, with the quit runs. No control fixture was left running after the suite. (The control-sockets PR's own suite run, without this fix, met the quit race under load and left one running.) - **The 18 remaining failures also fail on `main`:** `test_appimage_package_retry`, `test_collect_wide`, `test_framegen_resources`, `test_game_layout`, `test_low_memory_notice`, `test_mem_gate`, `test_mem_gate_children`, `test_mirror_trim`, `test_module_ids`, `test_oot_state_tap`, `test_pad_layout`, `test_pcache_header_read`, `test_pipeline_cache_file`, `test_pipeline_cache_save`, `test_pipeline_play_cache`, `test_prof_symbols`, `test_program_cache_cleanup`, `test_program_cache_one_variant`. The port's other PRs fix them, apart from three that are stale on `main` on every platform (`test_framegen_resources`, `test_module_ids`, `test_pad_layout`). **Not tested:** - **Linux.** It runs the same POSIX quit case and gets the two new runs. Not run here. - **Windows.** Its run list and code path are unchanged; `check_failed` only prints and exits there, as before. **Needs other testers:** - Linux: `tests/test_control.py`. Expect the two new quit PASS lines and no hang (request to open). - Windows: `tests/test_control.py`. Expect the same cases as `main` (request to open). ## Screenshots Nothing on screen. ## Risk and rollback - Test code only, apart from the hook, which only the test build (`HOS_CONTROL_NO_GAME`) compiles. - The parent now waits 10 s at most for the child's word. A bridge that takes longer to start fails the check instead of hanging it. - Rollback: revert the PR. ## Reviewer notes - Start with `quit_case` and `check_failed` in `tests/control_test.c`. - Left out on purpose: the bridge could make its socket's file appear only once it listens (bind under another name, listen, rename). That changes the game on every platform, and the real clients already retry. - Review only `26959952`, this PR's own commit; `3e335a41..f1f1843d` is the control-sockets PR. ## Issues See #420. ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. (Branched from 5e072fc8, on the control-sockets PR it depends on.) - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). (526 passed, 17 skipped, and 18 failures that also fail on `main`, named under Testing.) - [x] Tested in the game, or what wasn't is named under Testing. (No game run: a test fixture; named under Testing.) - [ ] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). The test requests are still to open. - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (For a `test` change: the new runs hang without the fix and pass with it; the control checks on `main` and here, two interleaved runs each.) - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] A game-free test added for the fix, failing on `main` and passing here. (The two new runs hang without the fix and pass with it; on `main` the fixture does not build on macOS.) - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Not built there; the change to `host/control.c` is only in the test build.
Use public Darwin socket, fcntl and getpeereid calls behind Apple-only
branches. Keep the real sockaddr_un path bound, set sun_len, and retain
private-directory and mode checks. Fail closed on peer verification.

Distinguish full disconnects from half-closes without SIGPIPE. Keep full
live listen queues safe from stale-socket removal and allow queued client
bursts to reach the existing busy reply. Leave Linux and Windows source
paths unchanged.

Extend the real game-free control fixture for both Apple clang targets,
including descriptor flags and an exec child. Document the non-atomic
fcntl window and the separate future macOS process-lifecycle requirement.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(control): link the bridge's macOS socket helpers into test_oot_state_tap's fixture
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
PR description / Description follows the template (pull_request_target) Successful in 6s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 12s
Website / Website on Cloudflare (pull_request_target) Has been skipped
f1f1843db3
Main's tests/test_oot_state_tap.py (sampled-state streaming, #58) builds
host/control.c into its own fixture. On macOS, control.c now calls the
Unix socket helpers in host/posix/darwin_control_sockets.c, so the
fixture adds that file there, as tests/test_control.py does. The
Windows and Linux compile commands are unchanged.

With the runner PR's repair of the fixture's vsnprintf macro, the check
passes on macOS; without it, it still stops at that macro.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(control): quit's parent waits for its bridge to listen, and a failed check ends the bridge it forked
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 7s
CI / Conventional Commit title (pull_request) Successful in 5s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 9s
Website / Website on Cloudflare (pull_request_target) Has been skipped
2695995219
control_test's quit case (Linux and macOS) took the socket's file for a
listening bridge: the bridge binds, which makes the file, then listens,
and a connect between the two is refused. The parent then failed its
check and exited, while the child it forked served on holding the
parent's stdout and stderr, so test_control.py waited for them to its
180 s limit and the child was left running for good. On this Mac under
the full suite's load it happened in about 1 run of 150 (macOS refuses
with ECONNREFUSED as Linux does).

The child now says on a pipe that hos_control_serve returned, which is
after listen; a failed check ends the forked bridge before the process
exits; the parent waits 5 s at most for the child's exit, as Windows
does. Two new runs make the races deterministic: quit slow (the test
build's bridge waits 300 ms between bind and listen, a hook compiled
only with HOS_CONTROL_NO_GAME) and quit fail (a check fails once the
child serves; it must end with 1 and leave nothing behind). Both hung
before this change. The game's bridge is unchanged, and so is Windows.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 7s
CI / Conventional Commit title (pull_request) Successful in 5s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 9s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request has changes conflicting with the target branch.
  • docs/control.md
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'mac/control-quit:mooki-mac/control-quit'
git switch 'mooki-mac/control-quit'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!457
No description provided.