Skip to content

Await process snapshots on the read-only status path - #40

Merged
euforic merged 2 commits into
euforic/wonderful-mcnulty-8b981efrom
euforic/youthful-thompson-407acb
Oct 30, 2026
Merged

euforic merged 2 commits into
euforic/wonderful-mcnulty-8b981efrom
euforic/youthful-thompson-407acb

Conversation

@euforic

@euforic euforic commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Stacked on #38 (euforic/wonderful-mcnulty-8b981e); it needs that PR's async BoundedSubprocess.run overload. Retarget to main after #38 merges.

What changes

The status refresh no longer blocks a cooperative thread for up to 3 s per ps call. The read-only status methods on both launcher actors now await an async snapshot, so the ps wait suspends:

  • CodexInstanceController: statuses, statusBatch, stockStatus
  • ClaudeInstanceController: statuses, statusBatch, stockStatus, and status(for:appURL:), which wraps statuses

CodexProcessSnapshotProviding gains suspendingProcessSnapshot() async throws. A protocol extension provides a default that calls the synchronous processSnapshot(), so existing synchronous conformers (FixedProcessSnapshotProvider, SequencedProcessSnapshotProvider) conform unchanged. SystemCodexProcessSnapshotProvider and SystemClaudeProcessSnapshotProvider implement the async read with the async BoundedSubprocess.run. Both overloads share one set of ps arguments, the 3 s timeout, the 8 MiB output bound and the result checks.

The new requirement has a distinct name rather than an async overload of processSnapshot(). In an async context Swift resolves to the async overload, so open and closeProcesses, which are async functions that read the snapshot directly, would have been forced onto await.

Reentrancy review

Open, close and focus keep their synchronous snapshots: Codex open, closeProcesses, status(configuration:), and openStock/closeStock. The last two now call a private synchronous lifecycleStockStatus, because the public stockStatus is async. Claude's open and close flows use verifiedStatus, verifiedStockStatus, activateVerified* and isVerifiedProfileProcess, and those stay synchronous too. None of these flows gains a suspension point. The code between a lifecycle snapshot and the focus, terminate or signal that acts on it still runs without other actor work in between.

The new await is the only new interleaving, and it lets other actor work run while a status read waits on ps:

  • No shared state. Both actors hold only let properties, so an interleaving cannot corrupt anything.
  • Codex status reads compute statuses from the snapshot string alone after resuming. They make no identity checks.
  • Claude status reads run every helper and main-process identity check after the snapshot returns, with no suspension between them. That covers kernel start key before and after, kernel-resolved executable containment, and the dynamic publisher signature. An await never splits a check.
  • Status results never authorize an action. The app uses only isRunning from them, and AgentDockModel.refreshInstanceStatuses already re-reads when a lifecycle action happens during a batch. Every open, close or focus takes its own synchronous snapshot and re-verifies identity.

The one effect: a status read can resume a little later when the actor is busy, for example with a lifecycle step's blocking ps, so it reflects a slightly older snapshot. That is display staleness, the same as a read finishing just before a close. It does not weaken any check.

Cancellation checks before and after each snapshot stay where they were. A cancelled async ps still terminates its process group and surfaces as processInspectionUnavailable (Codex) or BoundedSubprocessError.timedOut (Claude), as the polled synchronous wait did. DesktopInstanceController's per-provider failure isolation is unchanged.

Tests

  • testConcurrentStatusBatchesDoNotHoldCooperativeThreads: 3 × core-count statusBatch calls on separate controllers. The snapshot provider runs a real child process that prints a synthetic snapshot after one second. The calls finish in about 1.06 s and resolve the expected managed (222) and official (111) PIDs.
  • testStatusReadsOnOneControllerOverlapWhileWaitingForSnapshots: statusBatch, statuses and stockStatus on one controller overlap, finishing in about 1.02 s with matching results.
  • testSuspendingSystemSnapshotsListCurrentProcess: the real async ps reads for both providers include the test process, and Claude's parsed parent PID matches.

I checked that the first two tests catch the regression: with the read paths pointed back at the blocking processSnapshot(), both failed at about 3.06 s and 3.04 s against a 2.5 s limit.

swift test: passes. AgentDockCoreTests ran 340 tests (17 skipped), AgentDockAppTests 63 (3 skipped), TranscriptRendererTests 16, with 0 failures.

Benchmark

AGENTDOCK_BENCHMARK_BASELINE_DEFINE=CANDIDATE ./script/benchmark_resources.sh df0ad30 <out>. The new opt-in makes the #38 baseline run the same statusBatch call; by default the baseline uses the older per-provider APIs, which would measure a different change. Both apps are installed and signed; nine installed-status samples per revision:

baseline (#38) this PR
wall, median (range) 515.56 ms (407.9–584.2) 492.81 ms (464.3–564.9)
parent / child CPU 349.53 / 219.98 ms 340.32 / 220.91 ms
ps launches, validator entries 2, 1 2, 1
managed / official results 2 / 2 2 / 2

An earlier run with the same flags on a less loaded machine gave 421.81 → 334.47 ms. No regression; I'm not claiming a sequential latency improvement. Results are recorded in docs/resource-usage-benchmarks.md.

🤖 Generated with Claude Code

euforic and others added 2 commits October 30, 2026 00:49
Desktop status refreshes blocked a launcher actor's cooperative thread for
up to 3 s per `ps` call. The read-only status reads (`statuses`,
`statusBatch`, `stockStatus`, and Claude's `status(for:appURL:)`) now await
an async snapshot, so the wait suspends.

`CodexProcessSnapshotProviding` gains `suspendingProcessSnapshot()`, whose
default calls the synchronous snapshot, so existing synchronous conformers
still conform. The system Codex and Claude providers implement it with the
async `BoundedSubprocess.run` and share one set of `ps` arguments,
timeout, and output bound with their synchronous reads.

Open, close, and focus keep synchronous snapshots and gain no suspension
point. Official Codex open/close use a private synchronous stock status.
Both actors hold only immutable state. Status reads derive results from the
snapshot alone (Codex), or run every helper and main-process identity
check after it returns with no suspension between them (Claude). Status
results never authorize a lifecycle action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…evisions

The runner's baseline branch calls the older per-provider status APIs, so
its status rows cannot compare two revisions that both have `statusBatch`.
`AGENTDOCK_BENCHMARK_BASELINE_DEFINE=CANDIDATE` opts a baseline into the
candidate's calls; the default is unchanged.

Record the installed-status comparison for suspending snapshots: no
regression, same `ps` launches and validator entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@euforic
euforic merged commit 5bdda69 into euforic/wonderful-mcnulty-8b981e Oct 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant