Skip to content

fix(daemon): scope client timeout recovery to the timed-out request - #3193

Merged
thymikee merged 2 commits into
mainfrom
t3code/fix-issue-3177
Oct 5, 2026
Merged

thymikee merged 2 commits into
mainfrom
t3code/fix-issue-3177

Conversation

@thymikee

@thymikee thymikee commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

A timed-out request cancels its own connection without a host-wide runner sweep. Reset-eligible commands probe fresh HTTP health and socket RPC connections concurrently on a one-second absolute deadline; a responsive shared daemon survives.

An unresponsive daemon is retired through #3127's owning stopAndRetireDaemon, preserving process-birth checks, protected metadata/lock cleanup, termination evidence and typed retained-state outcomes. Main's abort/deadline transport implementation is retained unchanged.

Apple cancellation stops applicable owned prep children. Non-retained close stops current device prep before waiting on the runner session lock. Later prep respawn is explicitly outside this one-shot stop and tracked in #3220.

17 files; existing client-recovery/runner-close scope retained. Closes #3177.

Validation

Head 22025bcfab, rebased onto 7faae56e99. Frozen install/build, 57 focused controls, and liveness-gating mutation proof (3 red, restored 9 green route controls). pnpm check:affected --run passed: 3,666 related tests/477 files, 12 documentation controls and all selected runnable checks.

Earlier live cancellation, sibling snapshot/press, and close-during-build evidence remains attributed to b6cdd3c49; runner executable behavior is unchanged through this reconciliation. That run also measured the residual respawn now tracked in #3220. No new native run is claimed.

New-head GitHub checks and fresh conflict-resolution approval remain pending. No agent merge or ready-for-human label applied.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/daemon-client/daemon-client-timeout.ts Outdated
Comment thread src/daemon-client/daemon-client-liveness-probe.ts
Comment thread src/daemon-client/daemon-client-transport.ts
Comment thread src/daemon-client/__tests__/daemon-client-timeout-route.test.ts Outdated
Comment thread src/daemon-client/__tests__/daemon-client-liveness-probe.test.ts Outdated
Comment thread src/daemon-client/__tests__/daemon-client-liveness-probe.test.ts Outdated
Comment thread src/daemon-client/daemon-client-timeout.ts Outdated
Comment thread src/daemon-client/daemon-client-liveness-probe.ts Outdated
@thymikee
thymikee force-pushed the t3code/fix-issue-3177 branch from 66031ea to 14912a9 Compare October 3, 2026 20:33
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.04 MB 5.04 MB -282 B
Package (unpacked) 5.04 MB 5.04 MB -282 B
Package (download) 1.51 MB 1.51 MB -92 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.7 ms 28.8 ms +1.0 ms
CLI --help 82.1 ms 82.3 ms +0.2 ms

thymikee added a commit that referenced this pull request Oct 3, 2026
Review findings on #3193:

- The reset deleted daemon.json and daemon.lock unconditionally after a
  probe window during which a replacement daemon can publish. Re-read the
  registration through readRegisteredDaemonOwnership and clear it only on
  `match`; a replacement's record survives. The protocol lock is no longer
  touched at all: ADR 0030 gives reclaim to the acquirer under its mutation
  guard, so an out-of-band delete is the legacy-reclaimer pattern.
- The probe's detached HTTP build folded a malformed port into a negative
  answer instead of an unhandled rejection.
- The HTTP error listener returns when the timeout already claimed the
  outcome, so a destroyed request no longer also diagnoses a transport
  failure. The claim stays with the guarded reject, so a genuine socket
  death still settles.
- The reset hint stops claiming runner children stopped with a daemon killed
  by pid alone.
- Route tests count TCP connections instead of requests, seed ownership
  shaped registrations and a protocol lock dir, and assert no transport
  failure rides along with a timeout. Probe tests pin the /health path,
  gate the socket answer on the HTTP leg's receipt, and cover the malformed
  port.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/daemon-client/daemon-client-liveness-probe.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/daemon-client/__tests__/daemon-client-liveness-probe.test.ts
@thymikee

thymikee commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Thanks for the PR. The change looks right to me at 7d78066, but one live check is still missing, and Smoke Tests is still running.

The PR removes the client pkill of every runner xcodebuild and gates the daemon SIGKILL behind the probe (daemon-client-timeout.ts#L51). A timed-out request's runner work now stops only if the daemon-side cancel reaches the Apple runner build or launch signal through markRequestCanceled. The unit tests simulate the probe results, not a real daemon holding a cold runner, and the PR body says the "Done when" run from #3177 was not done. So two things are unproven on a device: that session B survives A's timed-out open, and that A's cold xcodebuild is stopped now that the sweep is gone, not left orphaned. Please run this on one daemon. Open session A and session B on two iOS simulators. Make open in A exceed its 90 s envelope, for example by clearing A's runner derived data so the runner builds cold. Then capture four things. First, A's error hint reads "The timed-out open request was canceled; the daemon was kept alive", and the daemon_request_timeout diagnostic shows daemonLivenessProbeAnswered:true and daemonPreservedAfterTimeout:true. Second, the daemon pid is the same before and after. Third, B's snapshot and press succeed afterward. Fourth, pgrep -fl xcodebuild shows B's runner still alive and no leftover xcodebuild for A's device.

Not blocking, and you can take or leave these: probeHttpHealth in daemon-client-liveness-probe.ts#L86 repeats the local /health GET that readDaemonHttpHealth already owns, and that function already has an absolute AbortSignal.timeout deadline, so the module comment's point about idle-only timeouts does not hold for the HTTP leg. Several comments are also paragraph-length, such as the loopback teardown comment in loopback.ts#L25, the probe module header, and the reset block in daemon-client-timeout.ts lines 182-209, and each could be one or two lines.

Would the probe be simpler if the HTTP leg used readDaemonHttpHealth with the remaining budget, leaving only the session_list socket leg and the race, about 60 lines? I would keep the socket leg, since a TCP connect alone cannot show that a blocked event loop is serving requests. That would need readDaemonHttpHealth exported with a caller-supplied budget, so it is not capped at the 500 ms local health timeout.

I did not run the tests. I checked that the new assertions fail against the old handleRequestTimeout by reading the code. I also did not check on a device that markRequestCanceled stops a cold runner xcodebuild mid-build. I only confirmed that the signal reaches runner-artifact and runner-process-launch. A daemon that answers the probe but is stuck on a non-cancellable lock now stays alive. That matches the issue's design, so I did not treat it as a defect.

Smoke Tests was still in progress at review time. Every CLI request in that suite goes through the changed sendSocketRequest and sendHttpRequest paths, so please read any smoke failure before calling it unrelated. Before merge, we need the live two-session run above and a finished Smoke Tests result.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/daemon-client/daemon-client-liveness-probe.ts Outdated
@thymikee

thymikee commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Tried the readDaemonHttpHealth reuse you suggested (c71b306) and reverted it in c153fe4 — it breaks two gates, and both ways of fixing that cost more than the ~60 lines saved.

Fallow caught the cycle. readDaemonHttpHealth lives in daemon-client-transport.ts, the transport imports handleRequestTimeout from daemon-client-timeout.ts, and the timeout handler loads the probe on demand — so the probe importing the transport is transport → timeout → probe → transport. The Compatibility & Provenance lane failed on exactly that.

The two hosts that break it are worse than the duplication.

  • New module: adds one module to src/cli.ts's eager closure (295 → 296), which the ADR 0019 loading-shape gate refuses — the probe's only caller is a timed-out request, so it must not be on the CLI's hot path.
  • Already-eager host (daemon-client-rpc.ts, the daemon-HTTP wire codecs): hosting a /health reader there is a coherent-enough neighbour, but it splits this PR across six ADR 0006 declarations — RemoteDaemonHealth, RemoteDaemonHealthLink, readHealthPayload, readHealthLink, readDaemonHttpHealth, readRemoteDaemonHealth — moving digests, re-keying five ledger acks, and re-pointing three planted-red mutation cases. I'd take that as its own change, not inside a timeout-scoping fix.

So the leg builds its own GET again. The two readers want genuinely different things anyway: the probe needs an absolute 1 s budget and a handle to retire the request when the socket leg answers first, while the reachability reader wants the 500 ms cap and a reachable verdict. That difference is now pinned by a test rather than argued in a comment: a 5xx is an answer for the probe, and reading reachable there would make a daemon serving 500 look silent and authorize the kill this issue is about.

You were right that the module comment over-claimed — the idle-timeout argument doesn't hold for the HTTP leg, whose reader already passes an absolute AbortSignal.timeout. Corrected the header to scope the claim to the socket leg, and took the other trims (loopback teardown, probe header, the reset block).

On the live check: agreed it's still owed, and I want to be precise about where I got. I could not force a cold runner build on this host — an mtime-only touch leaves the byte-digest artifact reuse intact (products identical, so buildMs: 0), and --ios-xctest-derived-data-path only redirects an external --ios-xctestrun-file, so it doesn't move the cache key on its own. Both sessions opened in ~1 s from the shared certified cache. So the four observations you listed are not captured yet. I'll get the cold build by a route that actually changes the compiled bytes and report the results here before asking for merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/daemon-client/daemon-client-liveness-probe.ts">

<violation number="1" location="src/daemon-client/daemon-client-liveness-probe.ts:96">
P2: This request inherits Node’s keep-alive global agent and can reuse an idle socket from an earlier request instead of testing a fresh connection. Set `agent: false` so the health probe always opens its own connection.</violation>
</file>

Comment thread src/daemon-client/daemon-client-liveness-probe.ts Outdated
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

The earlier comment's code concerns are now answered at c153fe4, but the live evidence is still missing. The remaining work is one device run.

The PR removes the host-wide runner xcodebuild kill and gates the daemon SIGKILL behind the liveness probe (daemon-client-timeout.ts#L51). A timed-out open's cold xcodebuild now stops only if markRequestCanceled reaches the Apple runner build or launch on the daemon. No device run shows that, and none shows that the sibling session survives. So two things are unproven: session B might not survive A's timed-out open, and A's cold xcodebuild might be left orphaned now that the sweep is gone. Please run both sessions on one daemon with two iOS simulators. Push A's open past its 90 s envelope with a truly cold runner build. Change the compiled runner bytes or use a fresh runner build root, because an mtime touch still reuses the earlier build. The run should show four things. First, A's hint says the daemon was kept alive, and the daemon_request_timeout diagnostic shows daemonLivenessProbeAnswered:true and daemonPreservedAfterTimeout:true. Second, the daemon pid is the same before and after. Third, B's snapshot and press succeed afterward. Fourth, pgrep -fl xcodebuild shows B's runner alive and no xcodebuild left for A's device UDID.

On the open inline threads, the liveness probe socket thread (P2) still applies. The probe request passes no agent, so it can reuse an idle keep-alive socket. A stale socket the server closed can return ECONNRESET and read as "unresponsive". That only decides the result on HTTP-only daemons. Would you add agent: false there? The headers-only health read thread (P2) no longer applies, because the probe now settles when the response headers arrive; please resolve it.

CI is green, with all 19 checks passing at c153fe4. The change since the last review is comments plus one unit test, so CI has not exercised any new behavior. I did not run tests, fallow, or the ADR 0019 closure gate, and I checked the cycle and gate claims from the import graph only. I also did not check on a device whether cancellation stops a cold runner build. No conflicts are known. The device run above must pass before merge.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Ran the live two-session check from the checklist. Both sessions share one daemon (--state-dir), so the sibling really is on the same host daemon.

Setup: one dev daemon (pid 85431), session live-b serving B = iPhone Duo / com.callstack.agentdevicelab (baseline snapshot verified before the timeout). Session live-a replays a script on A = iPhone 18 Pro that stays in flight (open + a 60 s wait for a missing label) under --timeout 8000.

Obs 1 — hint + diagnostics ✅

code: COMMAND_FAILED  message: Daemon request timed out
reason: daemon_transport_timeout  timeoutMs: 8000  requestId: 2902538d23cb282f
hint: "…The timed-out replay request was canceled; the daemon was kept alive
       so the session can still be closed or inspected."

Per-request diagnostic mutf2c1v-055a286e, phase daemon_request_timeout:

{"timeoutMs":8000,"requestId":"2902538d23cb282f","command":"replay",
 "daemonPreservedAfterTimeout":true,"daemonLivenessProbeAnswered":true}

Probe answered affirmatively → reset path never taken.

Obs 2 — daemon pid stable ✅ 85431 before and after; daemon.json still points at it; both live-a and live-b remain listed afterwards, so the timed-out session is still closable/inspectable as the hint claims.

Obs 3 — sibling B unaffected ✅ after A's timeout, on the same daemon:

  • snapshot → success: true (45 nodes)
  • press label="Drag source" → success: true

Obs 4 — runner teardown is request-scoped ✅ sampled xcodebuild test-without-building once per second across the window:

B's runner (9542F4F5) A's runner (BDA9B772)
in flight T+1…T+7s alive 2 procs
after timeout T+8…T+14s alive, never re-spawned 1 proc (test run torn down, client-side cancel)

B's runner is never killed and never has to cold-start again, and no host-wide sweep happens — A's simctl/container procs for BDA9B772 are what remain, B's is untouched.

Reproduce: replay <script with a long wait> --state-dir <shared> --session live-a --udid <A> --timeout 8000 while snapshot/press run on live-b against the same --state-dir.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Pushed c05c923 for the two cubic findings (keep-alive reuse was valid against the current code and is fixed with agent: false; the header-settling was already restored by the c153fe4 revert and is now pinned by a test — details in each thread).

The live two-session evidence above was captured at c153fe4. This commit only changes the probe's own connection shape, so rather than re-run the full device choreography I re-verified the shipped dist artifact (what the live run executes) directly:

  • against the real dev daemon of a live session: probeDaemonResponsive → true (socket leg)
  • keep-alive: primed the node:http pool, then probed HTTP-leg-only → true with the server seeing 2 distinct client sockets (no reuse)
  • headers-then-stalled-body endpoint → true in 1 ms (settles at header arrival, not deadline)

Same four observations therefore still stand; the delta at this head is probe-internal and verified at the artifact level.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

T3 flagged Android / Smoke Tests failing on c71b306 — investigated; no action needed and nothing blocks the merge.

  • c71b306 is superseded: it was reverted by c153fe4 minutes after that run started, so the failed job tested a tree that no longer exists on this branch. All four smoke jobs pass on both c153fe4 and 7d78066.
  • The failure itself is unrelated to this PR regardless: assertPersistentAndroidHelper saw helperTransport: 'instrumentation' where it expects 'persistent-session' — the Android snapshot helper falling back after its persistent session didn't come up (the run's adb retried exit code 1 ~10 times just before). This PR changes zero Android code; the touched files are all src/daemon-client/* timeout/probe paths plus their tests.

Current head c05c923d's smoke matrix is running; watching it.

thymikee added a commit that referenced this pull request Oct 4, 2026
Review findings on #3193:

- The reset deleted daemon.json and daemon.lock unconditionally after a
  probe window during which a replacement daemon can publish. Re-read the
  registration through readRegisteredDaemonOwnership and clear it only on
  `match`; a replacement's record survives. The protocol lock is no longer
  touched at all: ADR 0030 gives reclaim to the acquirer under its mutation
  guard, so an out-of-band delete is the legacy-reclaimer pattern.
- The probe's detached HTTP build folded a malformed port into a negative
  answer instead of an unhandled rejection.
- The HTTP error listener returns when the timeout already claimed the
  outcome, so a destroyed request no longer also diagnoses a transport
  failure. The claim stays with the guarded reject, so a genuine socket
  death still settles.
- The reset hint stops claiming runner children stopped with a daemon killed
  by pid alone.
- Route tests count TCP connections instead of requests, seed ownership
  shaped registrations and a protocol lock dir, and assert no transport
  failure rides along with a timeout. Probe tests pin the /health path,
  gate the socket answer on the HTTP leg's receipt, and cover the malformed
  port.
@thymikee
thymikee force-pushed the t3code/fix-issue-3177 branch from c05c923 to f7d4ed6 Compare October 4, 2026 06:24
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (033e55d) — force-pushed as f7d4ed6. No conflicts: main's new work (selectors export, iOS fill/clear-app-state fixes, limrun, session artifact paths) is disjoint from the src/daemon-client/* timeout/probe surface. Gates re-run on the rebased head: tsc, 178 daemon-client tests, wire-compat, CLI eager-closure, layering, Fallow vs origin/main, lint — all green. All review threads remain addressed and resolved; no open comments.

thymikee added a commit that referenced this pull request Oct 4, 2026
Review findings on #3193:

- The reset deleted daemon.json and daemon.lock unconditionally after a
  probe window during which a replacement daemon can publish. Re-read the
  registration through readRegisteredDaemonOwnership and clear it only on
  `match`; a replacement's record survives. The protocol lock is no longer
  touched at all: ADR 0030 gives reclaim to the acquirer under its mutation
  guard, so an out-of-band delete is the legacy-reclaimer pattern.
- The probe's detached HTTP build folded a malformed port into a negative
  answer instead of an unhandled rejection.
- The HTTP error listener returns when the timeout already claimed the
  outcome, so a destroyed request no longer also diagnoses a transport
  failure. The claim stays with the guarded reject, so a genuine socket
  death still settles.
- The reset hint stops claiming runner children stopped with a daemon killed
  by pid alone.
- Route tests count TCP connections instead of requests, seed ownership
  shaped registrations and a protocol lock dir, and assert no transport
  failure rides along with a timeout. Probe tests pin the /health path,
  gate the socket answer on the HTTP leg's receipt, and cover the malformed
  port.
@thymikee
thymikee force-pushed the t3code/fix-issue-3177 branch from f7d4ed6 to 921097f Compare October 4, 2026 06:43
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

The two earlier cubic-dev-ai points are fixed at 921097f: the timeout-only liveness probe now sets agent: false, and the new stall test covers the flushHeaders case. One piece of evidence is still missing, so this needs another live run before merge.

The PR removes the client's pkill -f xcodebuild build-for-testing …AgentDeviceRunner.xcodeproj sweep. A timed-out cold runner build now stops only if the daemon's markRequestCanceled reaches the running xcodebuild (daemon-client-timeout.ts#L17). The live run in comment 5977174059 timed out during a replay wait, after A's runner was already built and running under test-without-building. No build-for-testing was running when the timeout fired, so that run never reached the cold-build route. It shows the sibling session surviving, which is good, but it does not show A's build being stopped. If the cancel does not reach the build, a timed-out open leaves an orphaned build-for-testing on the shared derived-data root, and a retried open races it. On main the old sweep would have killed it, and the first "Done when" item in the issue names open. Could you run one more live check on 921097f? Keep session B running on simulator 2. For session A on simulator 1, force a cold runner build, either by changing the compiled bytes of a runner source (a comment does not count) or by using a fresh runner derived-data root. Then run open … --session A --timeout 8000 so the timeout lands mid-build. Please post four results: the daemon_request_timeout diagnostic for open with daemonLivenessProbeAnswered:true; the same daemon pid before and after; pgrep -fl 'xcodebuild build-for-testing' showing no process for A's UDID within a few seconds of the timeout; and B's snapshot and press succeeding.

I did not run tests or reproduce your device run, and I took the replay-run evidence from your comment. I also did not trace whether markRequestCanceled kills an in-flight build-for-testing subprocess, which is why the live run matters. I confirmed by reading the code that the agent: false test and the stall test would fail if their fixes were removed. Smoke Tests was still running at 921097f. Every smoke CLI request goes through the transport paths this PR changes, so please read any failure there before calling it unrelated. Only a failure on a timed-out request would overlap the new changes. No conflicts are known. Before merge, the cold-build open run above needs to pass and Smoke Tests needs to finish.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

At 921097f this branch now conflicts with main after today's merges. Please rebase onto main. The earlier review findings still apply; I will review the rebased head.

thymikee added a commit that referenced this pull request Oct 4, 2026
Review findings on #3193:

- The reset deleted daemon.json and daemon.lock unconditionally after a
  probe window during which a replacement daemon can publish. Re-read the
  registration through readRegisteredDaemonOwnership and clear it only on
  `match`; a replacement's record survives. The protocol lock is no longer
  touched at all: ADR 0030 gives reclaim to the acquirer under its mutation
  guard, so an out-of-band delete is the legacy-reclaimer pattern.
- The probe's detached HTTP build folded a malformed port into a negative
  answer instead of an unhandled rejection.
- The HTTP error listener returns when the timeout already claimed the
  outcome, so a destroyed request no longer also diagnoses a transport
  failure. The claim stays with the guarded reject, so a genuine socket
  death still settles.
- The reset hint stops claiming runner children stopped with a daemon killed
  by pid alone.
- Route tests count TCP connections instead of requests, seed ownership
  shaped registrations and a protocol lock dir, and assert no transport
  failure rides along with a timeout. Probe tests pin the /health path,
  gate the socket answer on the HTTP leg's receipt, and cover the malformed
  port.
@thymikee
thymikee force-pushed the t3code/fix-issue-3177 branch from 921097f to 477508e Compare October 4, 2026 10:58
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Thanks for the rebase. At 477508e the code change looks right. The rebase only adapts the patch to main's caller-abort work, and every handleRequestTimeout caller now handles the async result. All 19 checks pass, including the smoke jobs that go through the changed socket and HTTP timeout paths. There are no conflicts.

One thing still blocks merge: the cold-build open timeout still has no live run. With the client pkill sweep gone (daemon-client-timeout.ts#L17), a timed-out open stops its runner build only if the daemon's request-scoped cancel reaches xcodebuild build-for-testing. The existing live run timed out in a replay wait after the runner was already built, so it never reached that route. If the cancel does not reach the build, a timed-out open leaves an orphaned build on the shared runner derived-data root, and a retried open races it. This is the first "Done when" item of #3177. Could you run the check from my earlier comment on this head? Use one daemon, keep session B live on simulator 2, and give session A a truly cold runner build on simulator 1 (changed runner bytes or a fresh runner derived-data root). Then run A's open with a --timeout shorter than the build. Please post four things: A's daemon_request_timeout diagnostic with command:'open', daemonLivenessProbeAnswered:true and daemonPreservedAfterTimeout:true; the same daemon pid before and after; pgrep -fl xcodebuild during the build and a few seconds after the timeout, showing A's build-for-testing gone and B's runner alive; and B's snapshot and press succeeding afterward.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Ran the cold-build check on 477508e. The cancel does NOT reach the build on one route — reporting it as found, with the four artifacts.

Setup: one daemon (pid 48564, shared --state-dir). B = iPhone Duo 9542F4F5 session live-b, baseline open + snapshot + press role=button label="INFO" all green before the timeout. A = iPhone 17 Pro BC54AC11, cold build forced by changing compiled runner bytes (one 33 MB synthetic .m in the synchronized runner folder → runner_xctestrun_cache {action:"rebuild", reason:"cache_metadata_missing"} on the shared derived root ~/.agent-device/apple-runner/derived/ios-simulator/cache-ecbef53daded1755, no AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH).

First, a route constraint you'll want to know: on a local Simulator a plain open never stays in flight mid-build, so open --timeout 8000 cannot land mid-build there. lifecycle.ts schedules the cold runner build detached (finishAppleRunnerPrewarm only awaits when relaunch && !bridge; hasSimulatorBridge is true for every local iOS simulator), so open returns success:true in ~2–6 s while its build keeps running under the daemon. (Also: open's envelope is max(90 s, flag+30 s), so --timeout 8000 is a floor at 90 s anyway.) The request that actually waits mid-build is the first runner-demanding command — that's what I timed out.

A's run: replay <open --relaunch; press label=…> --udid BC54AC11 --timeout 35000 (replay's budget is exact). Build pid 50534 visible in-flight from t=3s; timeout fired at 35 s mid-compile.

Artifact 1 — diagnostic (daemon.log, 11:52:03Z):

{"phase":"daemon_request_timeout","session":"default","requestId":"e7afc49265a2b310","command":"replay",
 "data":{"timeoutMs":35000,"daemonPreservedAfterTimeout":true,"daemonLivenessProbeAnswered":true}}

CLI error: COMMAND_FAILED / "Daemon request timed out" / reason:"daemon_transport_timeout" with the daemon-preserved hint. (command:"replay" for the same route-shape reason above.)

Artifact 2 — daemon pid: 48564 before and after. ✅

Artifact 3 — FAILS: pgrep -fl xcodebuild build-for-testing shows 50534 (-destination …id=BC54AC11… -derivedDataPath …/cache-ecbef53daded1755) still alive every second for the full 20 s after the timeout, and still compiling 8+ minutes later. close on A's session did not stop it either. It died only to my manual kill. B's runner (48782 test-without-building) stayed alive throughout — no sweep happened, which is the PR working as designed; but the orphan is exactly the state the issue names.

Artifact 4 — partial: with the orphan still burning cores, B's snapshot and press both hit their own 90 s envelopes (runner restart + XCTest launch starved by the build; host at 4.96% idle). After I killed 50534, B recovered immediately: snapshot → 30 nodes, press role=button label="INFO" → success:true, press label="user-icon" → success:true. So B's stalls were orphan-caused — the retry-race/host-starvation failure mode #3177 exists to prevent — not a daemon-wide serialization bug.

Why the cancel misses (code chain):

  • lifecycle.ts createRunnerPrewarm.schedule() passes binding.signal — the open request's signal — into prewarmRunnerSession; runner-start-budget.ts:59 turns that into the build phase budget, and runner-artifact.ts:485 spawns xcodebuild with signal: options.budget.signal.
  • A later request (press/snapshot) waits for the same build under the runner session lock. Transport cancel (server/transport.ts markRequestCanceled) aborts only that request's signal — the build child is wired to the earlier request's signal, which was never aborted because open had already returned success. watchCommandAbort → killProcessTree therefore never fires.
  • The runnerPrepProcesses set + runner-disposal.ts stopRunnerPrepProcesses() (SIGTERM→SIGKILL tree kill) exist and do cover this build, but are only reached from stopAllIosRunnerSessions (daemon stop). releaseIosRunnerOnClose → stopIosRunnerSession never touches prep processes — matching the measured "close leaves the build alive".
  • On main the removed pkill sweep masked this on the next timeout; without it the orphan persists to completion on the shared root.

The detached: true + group-kill machinery itself works — the replay2-style run where the canceling request was the build's owning request (11:15Z, same head, daemon_request_timeout e5e731556d898b0f) killed the build within ~1 s (pid 83193 gone, prewarm_failed "request canceled" in its request log). The gap is specifically cross-request ownership: signal the prep-process kill path with the cancel of any request that is awaiting the build under the session lock (or thread the waiter's signal into the phase budget), rather than only the request that spawned it.

Cleanup after the run: planted .m removed (git status clean), polluted cache dirs deleted, both sessions closed, A's session released. My earlier attempt at this check with 4×33 MB sources saturated the host enough to starve B's baseline too — that run is environmental noise and excluded from the above; this run used a single synthetic file and a green B baseline.

Not merging. Happy to wire the waiter-signal into the prep-process kill path on this branch if you want it in scope; otherwise this belongs as a follow-up issue since main's sweep was papering over it.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Thanks for the cold-build run on 477508e. It settles the open evidence item, and the result is a blocker. The timed-out request cancels only its own signal. The runner build is tied to the earlier open request, which already returned, so xcodebuild build-for-testing keeps running on the shared derived-data root. close does not stop it either. Before this PR, the client pkill sweep cleaned this up on the next timeout. With the sweep removed, the orphaned build stays alive and starves other sessions, as B's 90 s stalls showed.

The smallest fix is the one you name: when a request that waits on the build under the runner session lock is canceled, stop the prep processes through the existing runnerPrepProcesses kill path (or pass the waiter's signal into the build phase budget). releaseIosRunnerOnClose should stop them too. Since this is the first "Done when" item of #3177, I think it belongs in this PR, not a follow-up. Please add a unit test for a canceled waiter stopping the build, and repeat the same live run on the new head.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/platform-apple/src/runner/runner-disposal.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-session.ts
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Follow-up fix landed and re-proven live on the new head.

Commit: 90cdee14f fix(ios): stop the device runner build when its waiter's request is canceled. The prep child set is now a ledger keyed by device (registerRunnerPrepProcess / runnerPrepProcessChildren). raceRunnerStartAgainstCaller gets the device id: a request cancel (client gone/timeout) stops that device's prep processes through the existing stopRunnerPrepProcesses tree-kill path; a caller deadline still leaves the start for the retry to join (#2894 preserved — the deadline branch is explicitly tested not to signal). releaseIosRunnerOnClose's stop branch also stops the device's in-flight build. No second kill path, no client-side pkill sweep. Unit tests: canceled waiter kills the build, deadline waiter doesn't, kill is device-scoped (sibling build untouched), close stops the build (runner-start-budget.test.ts, runner-session-close.test.ts).

Live proof on 90cdee14f (same scenario as my previous comment: cold build engineered on device A iPhone 17 Pro, sibling B iPhone Duo live, shared default runner derived root):

  1. Diagnostic — replay request f28de512f209e53c timed out at the flag's 35 s (daemon_transport_timeout, timeoutMs:35000). The waiter's cancel is in the request log at 13:21:44.277: ios_runner_session_prewarm_failed … "error":"request canceled" (snapshot_capture, 31832 ms).
  2. Daemon pid stable — BEFORE 12454, AFTER 12454. No reset, no force-kill, no liveness-probe contradiction needed.
  3. pgrep -fl xcodebuild during/after — A's build-for-testing pid 13016 (-destination …id=BC54AC11… -derivedDataPath …/cache-ecbef53daded1755) present every second from t=1 s to t=34 s; the request failed at t=35 s; at +1 s the build is gone, and stays gone through +20 s. No manual kill this time (contrast with 477508e where it survived 8+ min). B's runner test-without-building pid 71421 stayed alive throughout.
  4. Sibling B healthy without intervention — immediate post-timeout snapshot/press each spent the full 90 s and timed out: the planted source invalidated B's artifact too (ios_runner_session_artifact_stale → cache rebuild for B's key), which is expected for the shared-source cold-start, not the orphan race. Once that background rebuild settled, snapshot --session live-b returned success with 32 nodes and press label="INFO" returned success — no manual kills in either case.

Cleanup after the run: planted source removed (tree clean), A's polluted cache cache-ecbef53daded1755 deleted, dev daemon stopped (which now also aborts remaining prep as designed), foreign canary process untouched.

CI on 90cdee14f: guards/tests green as of posting (see checks). Not merging; awaiting re-review.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

The earlier finding is fixed in 90cdee1: a timed-out request now scopes recovery to itself, and the live cancel run in the earlier comment shows it. One issue remains, so this is not ready to merge yet.

The close ordering in releaseIosRunnerOnClose is still wrong. It calls stopIosRunnerSession, which takes the runner session lock that the start holds for the whole build, before it calls stopRunnerPrepProcesses(deviceId). A close during a build therefore kills the build only after it exits. An open that returns on its deadline while a prewarm or detached start is still building can reach this. Please stop the device prep before taking the lock, and add a test where a start holds the lock during the build. The test at runner-session-close.test.ts:329 registers the child with no start holding the lock, so it cannot catch this. Then please repeat the live close-during-build run and show the build child killed while the lock is still held. I read the code for this and did not run the close route. The open thread on this is #3193 (comment).

The other open thread, #3193 (comment), does not apply. The daemon's device execution lock in src/daemon/request-binding.ts serializes requests per device. A still-active request holds it, so no second request waits in ensureRunnerSession. Please resolve it.

Not blocking, and you can take or leave these: a start whose last waiter canceled should spawn no more prep work, but a cancel that lands before the spawn (reuse check, lease, boot, artifact check) finds an empty ledger, so runner-start-budget.ts:114 kills nothing and build-for-testing then starts. I inferred this window from the shape of the start phases and did not measure it. Tracking a waiter count or cancel signal on the start would close it. Also, runner-xctestrun.ts re-exports registerRunnerPrepProcess and runnerPrepProcessChildren only for tests, so tests could import from runner-artifact.ts and the re-export could go.

I did not re-run the live cancel run; I took it from the author's comment. Smoke Tests and Coverage are still running on 90cdee1 and no failure has been seen. This change touches the Apple runner start and close route that iOS smoke exercises, so a failure there would likely be related. There are no conflicts. Fixing the close ordering is the next step before merge.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Close-ordering fix + live close-during-build run + timeout re-confirm (heads b6cdd3c49, ade11bbd6).

Close ordering — fixed in b6cdd3c49. releaseIosRunnerOnClose now calls stopRunnerPrepProcesses(deviceId) before stopIosRunnerSession(deviceId), so the build is signaled without waiting for the per-device session lock the start holds for its whole cold build. Test where a start holds the lock during the build (your ask; the old runner-session-close.test.ts:329 test indeed could not catch it — verified by mutation): releaseIosRunnerOnClose stops the build while the start still holds the session lock (#3177) parks the start inside ensureXctestrunArtifact and asserts the kill lands while the lock is held; it fails against the old ordering. registerRunnerPrepProcess was additionally given the owning requestId, so a canceled waiter stops only builds whose owner can no longer cancel them (thread …4177817662, see below).

Live close-during-build on b6cdd3c49 (plain open on A/iPhone 17 Pro, which returns in ~4 s while the cold build continues detached under the daemon; one 33 MB planted source; shared derived root; then close --session proof-close --shutdown — --shutdown is what forces retainRunner:false through the close route):

  • open request d0b7ce37e4681e96 ok at 14:17:18; build-for-testing pid 32548 (id=BC54AC11…, -derivedDataPath …/cache-ecbef53daded1755) alive at t=15 s;
  • close request 002ab4de543ae437 started 14:17:30; at CLOSE+0.6 s the build was gone while close was still running (it did not return until +90 s) — under the old ordering stopIosRunnerSession could only reach the build after the start released the lock, i.e. after the build finished on its own (~minutes).
  • Honest wrinkle in the same run: ~1 s after the kill, the detached prewarm's health retry re-entered the runner start and spawned a second build-for-testing (pid 34163, same destination/derived root, daemon-spawned). The one-shot prep sweep had already run, so close then waited on the session lock held by that retry and hit its own 90 s daemon timeout (daemonPreservedAfterTimeout:true, daemonLivenessProbeAnswered:true, daemon pid 32455 stable). daemon stop afterwards swept it; zero build-for-testing remained, foreign processes untouched. This is the live shape of your take-or-leave item 1 below.
  • Sibling/session state after: session closed, daemon stable, canary untouched; planted source removed, polluted cache key deleted.

Timeout-cancel artifacts re-confirmed on b6cdd3c49 (same scenario as before): replay request 748818b05a30dcf1 timed out at the flag's 35 s (daemon_transport_timeout); A's build-for-testing pid 138 (low pid from PID wrap; sole BFT in flight on the planted cache key) present t=1 s…34 s, gone by the +1 s post-timeout sample with no manual kill; daemon pid 99372 stable before/after; waiter's cancel in the request log (ios_runner_session_prewarm_failed … request canceled, 13:58:08.675). Sibling B: first post-timeout snapshot/press spent their 90 s while the shared-source artifact rebuild for B's key settled (expected cold-start cost of the planted source), then snapshot returned success with 32 nodes and press label="INFO" succeeded — no intervention.

Thread …4177817662: resolved with your reason — the daemon's device execution lock (src/daemon/request-binding.ts) serializes requests per device, so no second daemon request waits inside ensureRunnerSession. The ownership filter I added anyway stands as package-level defense for in-process concurrency that reaches raceRunnerStartAgainstCaller without that lock (each build's spawn now records its owning request; a canceled waiter never signals a build whose owner's request signal is still live; mutation-tested).

Take-or-leave decisions:

  1. Spawn-window guard (cancel before spawn → empty ledger → build starts anyway): not in this PR — but your inferred window is now measured: the close-during-build run above is the same family (the owning start re-spawns after a prep kill, and a one-shot sweep cannot catch spawns after it). The right fix is on the start itself (a start whose waiters are gone spawns no further prep work — waiter count or cancel flag on the start), not another sweep at the killer. I'd rather land that with its own measurement than bolt a second mechanism onto this fix.
  2. Test-only barrel re-export: dropped in ade11bbd6 — registerRunnerPrepProcess is no longer exported from runner-xctestrun.ts; tests import it from the owning runner-artifact.ts. The two children selectors stay on the barrel because runner-disposal.ts consumes them through it.

CI: b6cdd3c49 17 pass / Smoke pending at posting; ade11bbd6 checks now running (barrel/test-import-only change). Not merging.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Smoke triage for the iOS lane failure on b6cdd3c49 — flake, not this change:

  • The failed step was wait text Agent Device Tester (cold-start wait right after open --relaunch): the launched runner died runner_connect_failed_before_command_send mid-wait, the snapshot-triggered restart consumed the whole 60 s poll budget (wait_runner_restart_exhausted, 1 poll, 0 readable captures). None of my paths touch a launched runner or a registered session — the prep-kill only targets build-for-testing children, and here the session was already registered (runner pid/port live) before the wait began.
  • Same window, PR feat(limrun): change iOS settings on Limrun instances through simctl #3209 (simctl settings/Limrun — no runner-code changes) failed the same live iOS simulator fixture E2E at a different step, so the flake window was not head-specific.
  • Rerun evidence: the failed job on b6cdd3c49 passed on attempt 2 (run 37207437923), and on the current head ade11bbd6 all four smoke lanes + Coverage are green (the one lane that came back cancelled passed its rerun, run 37209975728).

No owning-type fix owed here from this failure; the residual flake shape (restart inside a 60 s first wait) is the budget family #2894/#3063 already own, not this PR's route.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

The PR is ready for human review: I found no new problems at ade11bb, and the earlier findings from 90cdee1 (#3193 (comment)) are now fixed. CI is green on ade11bb, including the Smoke Tests job that runs the Apple runner start and close route this change touches. There are no conflicts.

Not blocking, and you can take or leave these: (1) close stops the device's prep processes only once, so in your live run the start still holding the lock re-entered after the kill and spawned a second build-for-testing, which lived until daemon stop; the rule is that once teardown or the last waiter's cancel has happened, the start under the lock spawns no more prep work, which belongs on the start as one cancel flag checked before each spawn in ensureXctestrunArtifact, so please file the follow-up issue you proposed and trim the doc comment at runner-session.ts:678-684 to what this PR does; (2) the owner filter at runner-artifact.ts:106 guards a case the device execution lock in src/daemon/request-binding.ts already rules out, so one device-scoped stopRunnerPrepProcesses(deviceId) would do, unless a caller that reaches the race without that lock exists or is planned.

Would the smaller design work here, where you delete the owner-scoped ledger and its second stop entry point, and put one abort flag on the in-flight start? That flag would cover the real remaining gap and replace any ownership filtering. Deleting the filter needs nothing first, while the start-side flag needs its own measurement, as you propose.

On the open threads, the prep-stop ordering thread is fixed at this head (the stop now runs before stopIosRunnerSession takes the lock, with a test at runner-session-close.test.ts:356), so please resolve it. The other-active-owner thread does not apply, because the device execution lock serializes requests per device, so please resolve it too.

I did not reproduce the close-during-build run or the timeout-cancel run, and both come from your comment 5981188182. I did not run the new unit tests, so the claim that they fail before the fix comes from reading the code. I also did not trace which path the prewarm health retry takes when it re-enters the start after a prep kill, so the second-build observation rests on your live run.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 4, 2026
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

The code verdict for ade11bb is unchanged, but the branch now conflicts with main. I removed the ready-for-human label until it is rebased. After the rebase, I will check only the conflict resolutions.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 4, 2026
@thymikee
thymikee force-pushed the t3code/fix-issue-3177 branch from ade11bb to 22025bc Compare October 5, 2026 06:06
@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Reconciled #3193 with main's landed #3127 ownership work, preserving the accepted timeout policy and native runner behavior.

  • The liveness probe still uses fresh concurrent HTTP/socket legs and one absolute budget. Responsive daemons are preserved; the host-wide runner sweep is gone.
  • The reset now calls stopAndRetireDaemon and awaits its typed outcome. There is no second PID-signaling or metadata-unlink implementation in the timeout module. Retained state stays retained and is reported in the original timeout error. Protocol lock release/reclaim remains inside its acquisition owner.
  • daemon-client-transport.ts is byte-for-byte main, preserving budgeted health loading/response completion, caller-abort precedence and central awaited timeout handling. The abort-during-health regression remains in the focused suite.
  • Reset route controls now use real acquired registration-owner children. They prove confirmed termination, owned metadata/lock removal, retained state on EPERM, successor metadata retention and untouched flag-named paths. The duplicate lifecycle timeout row and its unused fixture were removed after retaining those assertions at the route boundary.
  • The reviewer-requested close comment is narrowed. Measured post-kill prep respawn is tracked in fix(apple-runner): fence prep spawns after teardown or last-waiter cancellation #3220. The request-owner filter remains unchanged in this reconciliation; fix(apple-runner): fence prep spawns after teardown or last-waiter cancellation #3220 explicitly reassesses it with start admission/waiter semantics rather than introducing another sweep.

Earlier native cancellation and close-during-build evidence remains attributed to b6cdd3c in #3193 (comment). Runner executable behavior is unchanged versus ade11bb (only its comment and an unused type re-export changed); this is not a new native run.

The new liveness-gating mutation produced 3 failures / 6 passes, restored to 9 passing route controls. The seven focused files passed 57 controls. The affected-gate result and final head are recorded in the updated PR description; new-head GitHub CI and your conflict-resolution review remain separate. Please recheck the reconciliation before restoring ready-for-human. No merge or label was applied.

@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

The PR is ready. The conflict with main is resolved at 22025bc, and the daemon timeout recovery now stays scoped to the timed-out request. It keeps main's retirement path from #3127 and adds only the liveness-probe gate in front of it. Checks are green: 19 checks, 0 not passing on 22025bc. There are no conflicts. The earlier review threads were already resolved, and nothing new is open. I did not rerun the focused suites or the liveness-gating mutation, so the 3-fail/6-pass result is as the author reported it. I also did not trace whether the replacement-registration route test reaches "registration-replaced" rather than "lock-busy" when process-lock reclaims the seeded lock. That fence belongs to main, not this change. No new native run was made on this head, because the runner delta is comment-only plus one unused re-export, so the earlier b6cdd3c evidence still applies. Not blocking, and you can take or leave these: the TEST_DAEMON_START_TIME comment in daemon-client-timeout-route.test.ts says the tests "never signal a process", but the reset rows and the EPERM row now spawn and SIGKILL real registration-owner children, so the comment can be dropped or limited to the seeded preserve rows. Also, daemonPidForceKilled in daemon-client-timeout.ts is now reported only when status is "retired", so a retained retirement whose SIGKILL succeeded (for example lock-busy) emits undefined; gating on retirement instead of resetDaemon would keep that fact, though daemonRetirement.termination still carries it. This needs a human review before merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 5, 2026
@thymikee
thymikee merged commit df7f5d4 into main Oct 5, 2026
19 checks passed
@thymikee
thymikee deleted the t3code/fix-issue-3177 branch October 5, 2026 08:11
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-05 08:11 UTC

thymikee added a commit that referenced this pull request Oct 5, 2026
…esh (#3220)

Review round on the fence found the over-refusal: a caller that merely
QUEUED behind a close is not that teardown's retry, and must not wake
to a canceled start once the close has settled. The lock task now
re-routes such a start to a fresh admission once the fence has left
the device — waiter interest follows the redirect — while a start
woken WHILE the fence governs the device stays refused, exactly the
mid-close retry the fence exists for. The settle clears the fence
inside the session lock so a woken start never sees a half-settled
teardown.

The starting request also counts as an interested waiter on surfaces
that pass no caller signal, so a joiner's cancellation can no longer
outvote the live owner and stop its build — the protection the removed
#3193 owner sniff carried, now by mechanism.
thymikee added a commit that referenced this pull request Oct 5, 2026
…3220)

A non-retained close killed the in-flight `build-for-testing` before
waiting for the session lock the start holds, and the same caller's
health retry answered the kill with a second build while close waited —
close timed out on the build it just asked to stop.

One start-owned admission now answers who may prepare a device: it is
captured when a start requests the device, shared by every start that
queues behind that work, closed by a teardown BEFORE it stops prep
children or takes the session lock, and closed by the LAST interested
waiter's cancellation while any other waiter still preserves the work.
The preparation-spawn seam and the session-publish points both read it
immediately before they act, so a retired start's retry is refused
before the replacement child exists, and a retired start can never
publish into a fresh start. A start that merely QUEUED behind a close
re-routes to a fresh admission once that close has settled — the fence
refuses mid-close retries, never innocent waiters; the settle clears
the fence inside the session lock.

The starting request counts as an interested waiter even on surfaces
that pass no caller signal, so a joiner's cancellation cannot outvote
the live owner and stop its build.

Removed the #3193 request-owner prep filter
(`runnerPrepProcessChildrenWithoutActiveOwner` /
`stopRunnerPrepProcessesWithoutActiveOwner`): the admission's waiter
count supersedes the owner-liveness sniff. The shutdown-detach
mechanism moved to runner-adoption.ts beside the adoption it hands off
to (pure move).

Closes #3220
thymikee added a commit that referenced this pull request Oct 5, 2026
…3220)

A non-retained close killed the in-flight `build-for-testing` before
waiting for the session lock the start holds, and the same caller's
health retry answered the kill with a second build while close waited —
close timed out on the build it just asked to stop.

One start-owned admission now answers who may prepare a device: it is
captured when a start requests the device, shared by every start that
queues behind that work, closed by a teardown BEFORE it stops prep
children or takes the session lock, and closed by the LAST interested
waiter's cancellation while any other waiter still preserves the work.
The preparation-spawn seam and the session-publish points both read it
immediately before they act, so a retired start's retry is refused
before the replacement child exists, and a retired start can never
publish into a fresh start. A start that merely QUEUED behind a close
re-routes to a fresh admission once that close has settled — the fence
refuses mid-close retries, never innocent waiters; the settle clears
the fence inside the session lock.

The starting request counts as an interested waiter even on surfaces
that pass no caller signal, so a joiner's cancellation cannot outvote
the live owner and stop its build.

Removed the #3193 request-owner prep filter
(`runnerPrepProcessChildrenWithoutActiveOwner` /
`stopRunnerPrepProcessesWithoutActiveOwner`): the admission's waiter
count supersedes the owner-liveness sniff. The shutdown-detach
mechanism moved to runner-adoption.ts beside the adoption it hands off
to (pure move).

Closes #3220
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client timeout recovery ends sibling sessions: host-wide runner sweep on every timeout, daemon reset on shared daemons

1 participant