WIP: fix(control): keep command-line sessions alive on macOS #516

Draft
mooki wants to merge 2 commits from mooki/Longshot:mac/longshot-ctl-darwin into main
Member

Why

On macOS, scripts/longshot_ctl.py thinks every durable session (#47) is dead. process_start() reads
/proc/<pid>/stat, which macOS does not have. The game, its owner and its launcher all look gone, so sessions cannot be
attached, stopped or finished. tests/test_longshot_ctl.py fails on a Mac with 2 failures and 26 errors.

What changed

  • control: process_start() in scripts/longshot_ctl.py gets a Darwin branch that reads sysctl kern.proc.pid
    through ctypes, standard library only.
    • The identity is the process's start time: kp_proc.p_starttime, a struct timeval that the kernel sets at fork and
      never changes. It has microsecond resolution and is kept as {"clock": "darwin-start-us", "ticks": …}.
    • The boot's identity is added, kern.bootsessionuuid, as Linux adds boot_id.
    • A zombie (p_stat == SZOMB) or a pid that is gone (the call answers with no bytes) counts as dead, as Linux's Z/X
      does. The answer's own p_pid must match the pid asked for.
    • The struct kinfo_proc layout was checked with the SDK (offsetof). On arm64 and x86_64 alike it is 648 bytes, with
      the start time at 0, p_stat at 36 and p_pid at 40.
  • tests:
    • test_darwin_process_identity (Darwin only) checks four things:
      • The identity is stable, and its boot is sysctl -n kern.bootsessionuuid.
      • A child's start time lies between wall-clock readings taken before and after it is started, which checks the
        offsets.
      • An exited child that has not been reaped (a zombie that still holds its pid, os.kill(pid, 0)) reads as dead.
      • It still reads as dead after it is reaped.
    • test_copy_save compares launch's data folder with os.path.realpath(run). launch resolves --run
      (Path.resolve()), and on macOS /tmp is a link to /private/tmp. There is no platform case: on Linux the
      resolved path is the same.

Why sysctl and not ps -o lstart=: lstart has one-second resolution, so a pid reused within the same second would
pass as the same process, which is weaker than Linux's start ticks. It is also locale-formatted text from a subprocess,
about 7 ms per call on this Mac, and the session code polls same_process() every 20 ms. sysctl is one system call,
about 14 µs, with microsecond resolution and the zombie state in the same answer. libproc's PROC_PIDTBSDINFO would give
the same start time. sysctl needs no second library, and ps reads the same kinfo_proc [inference: from how ps
works on BSD; its source was not read here].

Pid reuse: a reused pid belongs to a process forked later, so its p_starttime differs. The comparison is the whole
identity, as on Linux and Windows. The existing pid was reused check in Sessions still passes. It is strictly finer
than Linux's 10 ms ticks, and the boot UUID covers a reboot, as boot_id does.

Flags and settings

None.

Measurements

main: 9c9d69ed09 (2026-11-14)
this PR: mac/longshot-ctl-darwin @ 526953848f
Hardware: Apple M4 Max (16 CPUs), 64 GB, macOS 26.7.1
Scene: none (game-free checks; no GPU, no game)
Method: tests/run_all.py test_longshot_ctl, run main, this PR, main, this PR. The results are pass or fail and the same
in both rounds, so there is no timing median. Also the full game-free suite, compared by test name with main's run on
this Mac.
How to reproduce: python -I -B tests/test_longshot_ctl.py in each checkout. Add
--source-root <checkout> Sessions.test_darwin_process_identity to point one test at another checkout's
longshot_ctl.py.

main @ 9c9d69ed this PR @ 52695384
tests/test_longshot_ctl.py, rounds 1 and 2 FAIL: 38 tests, 2 failures, 26 errors PASS: 39 tests, OK
test_darwin_process_identity pointed at main's longshot_ctl.py ERROR: 'NoneType' object is not subscriptable (no identity on Darwin) PASS
full game-free suite on macOS (tests/run_all.py --jobs 6) 533 passed, 20 failed, 17 skipped 534 passed, 19 failed, 17 skipped
main @ 9c9d69ed:
FAIL: test_copy_save (__main__.LongshotCtl.test_copy_save)
FAIL: test_profile_manifest_and_second_client (__main__.Sessions.test_profile_manifest_and_second_client)
Ran 38 tests in 14.693s
FAILED (failures=2, errors=26)

this PR @ 52695384:
Ran 39 tests in 12.853s
OK

The new check catches real faults. In a scratch copy of this PR's longshot_ctl.py with the zombie check
(p_stat == 5) removed, it fails at the zombie ({'clock': 'darwin-start-us', …} is not None). This PR's copy passes.

Testing

Tested:

  • On an Apple M4 Max with macOS 26.7.1, game-free only.
  • test_longshot_ctl fails on main and passes here, in two interleaved rounds.
  • The struct offsets on arm64 and on x86_64 (Rosetta) with the SDK's offsetof.
  • Probe of the live process, a child, the child as a zombie, after reaping, pid 1 and a pid that does not exist
    (reports/MacMainFixes-files/probes/ps_probe.py).
  • The full suite, compared with main by test name. Only one status changed: tests/test_longshot_ctl.py, FAIL on main,
    passes here. The other 19 failures are main's on this Mac, by name. Trees were clean before and after, and the run
    took 179 s.

Not tested:

  • Linux and Windows. Their paths in process_start() are unchanged. test_copy_save's expectation resolves to the same
    path where /tmp is not a link [inference: not run there].
  • Rosetta (an x86_64 Python): the layout is the same (checked), but the test was run with arm64 Python only.
  • A real game session on macOS.

Needs other testers: None to merge. Linux and Windows CI would confirm that nothing changed there.

Screenshots

Nothing on screen.

Risk and rollback

macOS only. If sysctl fails or answers with the wrong size, the process reads as dead. That is the same outcome as
before this PR, so it fails safe. Reverting the two commits restores main.

Reviewer notes

  • Base: this PR is on main 9c9d69ed. Main has since moved to 543846eb (#385, #465), and neither commit touches
    these files. git merge-tree --write-tree 543846eb mac/longshot-ctl-darwin merges cleanly.

  • Where to look: the Darwin block in process_start(). Sessions store the identity as JSON, so it is plain ints and a
    string.

  • ctypes.CDLL(None) finds sysctl and sysctlbyname in libSystem through the interpreter's own image.

Issues

See #47 (and #420, macOS tracking).

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main. Branched from 9c9d69ed; main has moved to 543846eb, which merges cleanly (neither new commit touches these files). It is rebased in the sweep before ready.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests).
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. Two rounds in turn; the results are pass or fail and the same in both rounds, so there is no timing median.
  • 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.
## Why On macOS, `scripts/longshot_ctl.py` thinks every durable session (#47) is dead. `process_start()` reads `/proc/<pid>/stat`, which macOS does not have. The game, its owner and its launcher all look gone, so sessions cannot be attached, stopped or finished. `tests/test_longshot_ctl.py` fails on a Mac with 2 failures and 26 errors. ## What changed - control: `process_start()` in `scripts/longshot_ctl.py` gets a Darwin branch that reads `sysctl` `kern.proc.pid` through `ctypes`, standard library only. - The identity is the process's start time: `kp_proc.p_starttime`, a `struct timeval` that the kernel sets at fork and never changes. It has microsecond resolution and is kept as `{"clock": "darwin-start-us", "ticks": …}`. - The boot's identity is added, `kern.bootsessionuuid`, as Linux adds `boot_id`. - A zombie (`p_stat == SZOMB`) or a pid that is gone (the call answers with no bytes) counts as dead, as Linux's `Z`/`X` does. The answer's own `p_pid` must match the pid asked for. - The `struct kinfo_proc` layout was checked with the SDK (`offsetof`). On arm64 and x86_64 alike it is 648 bytes, with the start time at 0, `p_stat` at 36 and `p_pid` at 40. - tests: - `test_darwin_process_identity` (Darwin only) checks four things: - The identity is stable, and its boot is `sysctl -n kern.bootsessionuuid`. - A child's start time lies between wall-clock readings taken before and after it is started, which checks the offsets. - An exited child that has not been reaped (a zombie that still holds its pid, `os.kill(pid, 0)`) reads as dead. - It still reads as dead after it is reaped. - `test_copy_save` compares launch's data folder with `os.path.realpath(run)`. `launch` resolves `--run` (`Path.resolve()`), and on macOS `/tmp` is a link to `/private/tmp`. There is no platform case: on Linux the resolved path is the same. **Why sysctl and not `ps -o lstart=`:** `lstart` has one-second resolution, so a pid reused within the same second would pass as the same process, which is weaker than Linux's start ticks. It is also locale-formatted text from a subprocess, about 7 ms per call on this Mac, and the session code polls `same_process()` every 20 ms. sysctl is one system call, about 14 µs, with microsecond resolution and the zombie state in the same answer. libproc's `PROC_PIDTBSDINFO` would give the same start time. sysctl needs no second library, and `ps` reads the same `kinfo_proc` [inference: from how `ps` works on BSD; its source was not read here]. **Pid reuse:** a reused pid belongs to a process forked later, so its `p_starttime` differs. The comparison is the whole identity, as on Linux and Windows. The existing `pid was reused` check in `Sessions` still passes. It is strictly finer than Linux's 10 ms ticks, and the boot UUID covers a reboot, as `boot_id` does. ## Flags and settings None. ## Measurements main: 9c9d69ed09e2c24965d35f1696aa5c8dd8fe6e40 (2026-11-14) this PR: mac/longshot-ctl-darwin @ 526953848f9481a24d32b99a131319dc843fd043 Hardware: Apple M4 Max (16 CPUs), 64 GB, macOS 26.7.1 Scene: none (game-free checks; no GPU, no game) Method: `tests/run_all.py test_longshot_ctl`, run main, this PR, main, this PR. The results are pass or fail and the same in both rounds, so there is no timing median. Also the full game-free suite, compared by test name with main's run on this Mac. How to reproduce: `python -I -B tests/test_longshot_ctl.py` in each checkout. Add `--source-root <checkout> Sessions.test_darwin_process_identity` to point one test at another checkout's `longshot_ctl.py`. | | main @ 9c9d69ed | this PR @ 52695384 | |---|---|---| | `tests/test_longshot_ctl.py`, rounds 1 and 2 | FAIL: 38 tests, 2 failures, 26 errors | PASS: 39 tests, OK | | `test_darwin_process_identity` pointed at main's `longshot_ctl.py` | ERROR: `'NoneType' object is not subscriptable` (no identity on Darwin) | PASS | | full game-free suite on macOS (`tests/run_all.py --jobs 6`) | 533 passed, 20 failed, 17 skipped | 534 passed, 19 failed, 17 skipped | ``` main @ 9c9d69ed: FAIL: test_copy_save (__main__.LongshotCtl.test_copy_save) FAIL: test_profile_manifest_and_second_client (__main__.Sessions.test_profile_manifest_and_second_client) Ran 38 tests in 14.693s FAILED (failures=2, errors=26) this PR @ 52695384: Ran 39 tests in 12.853s OK ``` The new check catches real faults. In a scratch copy of this PR's `longshot_ctl.py` with the zombie check (`p_stat == 5`) removed, it fails at the zombie (`{'clock': 'darwin-start-us', …} is not None`). This PR's copy passes. ## Testing **Tested:** - On an Apple M4 Max with macOS 26.7.1, game-free only. - `test_longshot_ctl` fails on main and passes here, in two interleaved rounds. - The struct offsets on arm64 and on x86_64 (Rosetta) with the SDK's `offsetof`. - Probe of the live process, a child, the child as a zombie, after reaping, pid 1 and a pid that does not exist (`reports/MacMainFixes-files/probes/ps_probe.py`). - The full suite, compared with main by test name. Only one status changed: `tests/test_longshot_ctl.py`, FAIL on main, passes here. The other 19 failures are main's on this Mac, by name. Trees were clean before and after, and the run took 179 s. **Not tested:** - Linux and Windows. Their paths in `process_start()` are unchanged. `test_copy_save`'s expectation resolves to the same path where `/tmp` is not a link [inference: not run there]. - Rosetta (an x86_64 Python): the layout is the same (checked), but the test was run with arm64 Python only. - A real game session on macOS. **Needs other testers:** None to merge. Linux and Windows CI would confirm that nothing changed there. ## Screenshots Nothing on screen. ## Risk and rollback macOS only. If sysctl fails or answers with the wrong size, the process reads as dead. That is the same outcome as before this PR, so it fails safe. Reverting the two commits restores main. ## Reviewer notes - Base: this PR is on main `9c9d69ed`. Main has since moved to `543846eb` (#385, #465), and neither commit touches these files. `git merge-tree --write-tree 543846eb mac/longshot-ctl-darwin` merges cleanly. - Where to look: the Darwin block in `process_start()`. Sessions store the identity as JSON, so it is plain ints and a string. - `ctypes.CDLL(None)` finds `sysctl` and `sysctlbyname` in libSystem through the interpreter's own image. ## Issues See #47 (and #420, macOS tracking). ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [ ] Rebased on current `main`. Branched from 9c9d69ed; main has moved to 543846eb, which merges cleanly (neither new commit touches these files). It is rebased in the sweep before ready. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [ ] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). - [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. Two rounds in turn; the results are pass or fail and the same in both rounds, so there is no timing median. - [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.
process_start() read /proc, so on macOS every session's game, owner and launcher looked dead. On Darwin it now reads sysctl kern.proc.pid through ctypes: the start time (a microsecond struct timeval set at fork, never adjusted) with kern.bootsessionuuid as the boot identity, and a zombie (p_stat SZOMB) or a gone pid is dead, as on Linux. A reused pid has a different start time. Standard library only, no subprocess. Linux and Windows paths unchanged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(control): compare launch's data folder with the resolved run folder
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 4s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 8s
Website / Website on Cloudflare (pull_request_target) Has been skipped
526953848f
launch resolves --run (Path.resolve), and macOS's /tmp is a link to /private/tmp, so test_copy_save now resolves its expected run folder too. No platform case: on Linux the resolved path is the same.

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 4s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 8s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request is marked as a work in progress.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'mac/longshot-ctl-darwin:mooki-mac/longshot-ctl-darwin'
git switch 'mooki-mac/longshot-ctl-darwin'
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!516
No description provided.