Skip to content

fix(ios-runner): return work that finished at the timeout boundary and bound recorder frame capture - #2837

Merged
thymikee merged 6 commits into
mainfrom
fix/2782-main-thread-work-late-finish
Sep 24, 2026
Merged

thymikee merged 6 commits into
mainfrom
fix/2782-main-thread-work-late-finish

Conversation

@thymikee

@thymikee thymikee commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Two related Apple-runner main-thread-work bugs. #2782: runMainThreadWork could discard work
finishing exactly at its timeout boundary (a semaphore-timeout/result-lock race), rethrowing a
spurious timeout instead of the result; fixed at the lock/return seam. #2801: the recorder's
per-tick capture ran on an unbounded main.sync, letting a busy main thread queue a tick
indefinitely or block a command hop; ticks now go through runMainThreadWorkIfIdle with bounded
capture, declining while any main-thread hop is in flight.

Closes #2782, Closes #2801.

Validation

Tested at 349782642c, rebased on origin/main faa69ff784 (clean; #2836 and #2852 touched
RunnerTests+CommandDispatch.swift / RunnerTests.swift and add more runMainThreadWork callers, with
no semantic overlap with the gate). testRunMainThreadWorkIfIdleDeclinesOnlyForInFlightWork (fabricated
abandoned mark over an idle main) is replaced by testRunMainThreadWorkIfIdleDeclinesAbandonedWorkUntilItDrains,
which drives a real timeout: while the abandoned block still holds main, abandoned == 1 and
inFlight == 1, the optional tick declines and never dispatches; once drained it runs on main.
pnpm check:affected --run: all runnable checks passed (swift-runner-* / replay-* are
GitHub-authoritative). pnpm check:xctest-selection: 0 methods reachable by no lane. iOS Simulator runner
XCTest (iOS 26.2, fresh sim): every test in UnitTests/RunnerTests+MainThreadWorkTests.swift,
UnitTests/RunnerTests+RecordingTests.swift and UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift
plus testRecordStartThrowsTheCaptureRefusalItReceived — Executed 21 tests, with 0 failures.

Live runner-recorder run on an iOS simulator — full transcript, commands and screenshots in the
review comment: a 20.28 s window at fps: 10 exported duration=20.200000, nb_frames=185
(9.16 fps, 91% of the requested rate) while snapshot -i, two swipes and three runner-side
screenshots competed with the recorder ticks. agent-device screenshot answered during the
recording and again after recordStop, runnerMainThreadBusy stayed false on every answer, and
no command failed.

Honestly: on an iOS simulator, CLI record uses simctl (recordingBackend: "simctl recordVideo"), not the runner recorder (production path for physical devices and macOS record).
To reach the changed route, the runner's HTTP recordStart/recordStop were driven directly
alongside real CLI commands, so ticks competed with command hops on main. Idle yield was equal
pre/post-fix (~11.4 fps). Under command load, head captured ~15-20% fewer frames than pre-fix
(5.3-5.7 vs. 6.7 fps) — the intended skip-while-busy rule, not a regression (n=1/leg). Exports
decoded cleanly, no command failed; physical-device/macOS paths are unit-tested only.

Deviations for sign-off

  1. The per-frame timeout is a fixed 1 s floor, not "no longer than the frame interval" per fix(ios-runner): bound recorder frame capture through runMainThreadWork #2801.
    The strict bound (~8ms at high fps) would near-zero frames on a slow target; the floor was
    adopted instead. Open if the maintainer requires the interval bound.
  2. The in-flight gate skips every tick dispatched while a main-thread hop is in flight, not only
    ticks that follow an abandoned one. A healthy hop that finishes inside its slice marks nothing as
    abandoned, so a tick landing during it is dropped rather than appended, and a busy command
    surface costs frames: under command load head measured 15-20% fewer frames than pre-fix
    (5.3-5.7 vs. 6.7 fps, n=1/leg); the live 20 s run above kept 91% of the requested rate. The
    alternative — queueing the tick behind the hop — is what fix(ios-runner): bound recorder frame capture through runMainThreadWork #2801 reported, and it also converts the
    tick into abandoned work that stamps runnerMainThreadBusy and skips XCTest snapshot tiers. Open
    if the maintainer would rather see the skipped-tick share disclosed per recording.

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.80 MB 4.80 MB +2.1 kB
Package (unpacked) 4.79 MB 4.80 MB +2.1 kB
Package (download) 1.43 MB 1.44 MB +403 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.1 ms 30.3 ms +2.1 ms
CLI --help 80.8 ms 84.3 ms +3.5 ms

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-24 08:47 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at f1b04ec.

#2801 asked for a live runner-recorder check on an iOS simulator. The PR body says the recorder was reached through HTTP recordStart/recordStop, but it does not show the run length, frame count, commands, or a screenshot during and after the recording. A screenshot now competes with the new in-flight gate and the 1 s frame abandonment, so we need to see that it still works while recording. Can you paste one runner-recorder run of about 20 s, with agent-device screenshot during the recording and again after recordStop, plus the export's ffprobe duration and nb_frames, with exact commands and output?

Not blocking: the PR lists only the 1 s floor under "Deviation for sign-off", but the in-flight gate also skips every tick during any command hop, and the measurement shows 15-20% fewer frames under load, so it should be listed too. Also, in production mainThreadWorkInFlightCount == 0 already implies abandonedMainThreadWorkCount == 0, so the abandoned term in the guard seems to fire only in tests. Is that intended?

Is there a smaller design here? runMainThreadWorkIfIdle reuses the existing enqueue and await helpers, and the in-flight counter looks like the minimum state that stops a recorder tick from queueing behind a hop. If you considered keeping the recorder on main.sync or the abandoned-only gate, a short note on why they were rejected would settle it.

Checks were still queued at review time. runMainThreadWork runs for every runner command, so iOS Smoke covers the changed hop, but not the recorder (simulator record uses simctl). The known live-snapshot-depth-frontier.ts:126 failure also fails on main and #2832 fixes it.

The next step is the live recorder run above and sign-off on the 1 s floor and the in-flight gate.

@thymikee
thymikee force-pushed the fix/2782-main-thread-work-late-finish branch from f1b04ec to 2a165d3 Compare September 23, 2026 21:09
@thymikee

thymikee commented Sep 23, 2026 •

Copy link
Copy Markdown
Member Author

Rebased onto origin/main b3b58caac1 (no conflicts — the branch touched RunnerTests+* Swift and website/docs/docs/commands.md, none of which main changed) and answered the three review questions. New head 40a7bac25f.

1. Live runner-recorder run on an iOS simulator

Setup. A simulator I created for this run: xcrun simctl create apex-2837 "iPhone 17" "com.apple.CoreSimulator.SimRuntime.iOS-26-2" → B91F5692-B722-4771-A0C6-A042BE700190 (destroyed afterwards). App: the repo's own Agent Device Tester (com.callstack.agentdevicelab), a locally built fixture bundle (/Users/thymikee/Developer/agent-device/.tmp/local-fixture-e2e4/AgentDeviceTester.app, arm64 simulator slice, com.callstack.agentdevicelab) installed with xcrun simctl install. The app is the target under observation, not the code under test: the changed code is the runner binary built from this head. CLI: node bin/agent-device.mjs from this worktree at 40a7bac25f; runner binary built with pnpm build:xcuitest (production config, AGENT_DEVICE_RUNNER_UNIT_TESTS off) from the same head. Session: apex-2837, isolated --state-dir /private/tmp/apex-2837-state.

One honest correction to the ask, before the evidence. On an iOS Simulator the CLI does not reach the recorder through HTTP — it spawns simctl io recordVideo. That is reproducible in one command:

$ node bin/agent-device.mjs record start --platform ios --udid B91F5692-… --session apex-2837 \
    --state-dir /private/tmp/apex-2837-state --fps 10 --json
{
  "success": true,
  "data": {
    "recording": "started",
    "recordingBackend": "simctl recordVideo",
    "recordingScope": "app",
    …

so the only way to drive the changed route on a simulator is the runner's own HTTP transport, as the body says. That part is reachable exactly as described: the runner is an NWListener HTTP server inside the AgentDeviceRunnerUITests-Runner process, iOS Simulators share the host network stack, and the port is in the runner lease — ~/.agent-device/apple-runner/leases/B91F5692-….json → "port": 49363. One JSON command per POST http://127.0.0.1:49363/, no auth.

The run (~20 s, fps: 10, wall clock 21:29:05Z → 21:29:25Z). Runner-side answers carry currentUptimeMs, so the window is measured on the runner's own clock: 1114203376.75 − 1114183101.04 = 20275.7 ms.

$ curl -s -X POST http://127.0.0.1:49363/ --data '{"command":"recordStart","outPath":"apex-2837-runner-20s.mp4","fps":10,"appBundleId":"com.callstack.agentdevicelab"}'
{"ok":true,"data":{"message":"recording started","runnerMainThreadBusy":false,"currentUptimeMs":1114183101.0446668}}

agent-device screenshot during the recording:

{ "success": true,
  "data": { "path": "/private/tmp/apex-2837-shots/during-recording.png",
            "width": 402, "height": 874, "logicalWidth": 402, "logicalHeight": 874,
            "pixelDensity": 1 } }

Then commands that genuinely contend with the recorder's ticks on the runner's main thread — snapshot -i ("success": true, visibleNodeCount: 29), two swipes (both "success": true; the runner logged gestureViewport + gesture COMMAND_ACCEPTED/COMPLETED … ok=1 for each), and three runner-side captures, which are the real competitor because they run through captureObservedScreen on main, exactly like a recorder tick:

$ curl -s -X POST http://127.0.0.1:49363/ --data '{"command":"screenshot","appBundleId":"com.callstack.agentdevicelab"}'
{"ok":true,"data":{"message":"tmp\/screenshot-1790198950512.png","runnerMainThreadBusy":false,"screenshotMetadata":{"displayID":1,"pixelWidth":1206,"pixelHeight":2622,"pixelsPerPoint":3},"currentUptimeMs":1114187782.4300418}}
{"ok":true,"data":{"message":"tmp\/screenshot-1790198953629.png","runnerMainThreadBusy":false,"screenshotMetadata":{"displayID":1,"pixelWidth":1206,"pixelHeight":2622,"pixelsPerPoint":3},"currentUptimeMs":1114190899.4704585}}
{"ok":true,"data":{"message":"tmp\/screenshot-1790198963902.png","runnerMainThreadBusy":false,"screenshotMetadata":{"displayID":1,"pixelWidth":1206,"pixelHeight":2622,"pixelsPerPoint":3},"currentUptimeMs":1114201172.1271667}}

A second agent-device screenshot during the recording (after the first swipe) also returned success: true → /private/tmp/apex-2837-shots/during-recording-2.png. Mid-window occupancy stamp: {"runnerMainThreadBusy":false,"currentUptimeMs":1114195054.09}.

$ curl -s -X POST http://127.0.0.1:49363/ --data '{"command":"recordStop","appBundleId":"com.callstack.agentdevicelab"}'
{"ok":true,"data":{"message":"recording stopped","currentUptimeMs":1114203376.751417,"runnerMainThreadBusy":false}}

agent-device screenshot after recordStop:

{ "success": true,
  "data": { "path": "/private/tmp/apex-2837-shots/after-record-stop.png",
            "width": 402, "height": 874, "logicalWidth": 402, "logicalHeight": 874,
            "pixelDensity": 1 } }

Export, read back off the host from the runner's own sandbox (resolveRecordingOutPath parks a relative outPath in the app container's tmp/ on iOS):

-rw-r--r--  … /Users/thymikee/Library/Developer/CoreSimulator/Devices/B91F5692-…/data/Containers/Data/Application/653FFF19-…/tmp/apex-2837-runner-20s.mp4   (2 178 722 bytes)

$ ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,nb_frames,duration,avg_frame_rate -show_entries format=duration,size -of default=noprint_wrappers=1 apex-2837-runner-20s.mp4
codec_name=h264
width=1206
height=2622
avg_frame_rate=925/101
duration=20.200000
nb_frames=185
duration=20.200000
size=2178722

What that says. 185 frames in a 20.28 s window at fps: 10 = 185/203 possible = 91% of the requested rate, 9.16 fps effective, at full native 1206×2622 (3×) — the ticks were not starved by the gate, and every contending command answered ok. The runner log for the window contains no MAIN_THREAD_WORK_ABANDONED marker at all: no tick ever became abandoned work, which is the outcome the in-flight gate exists to produce. A shorter first run behaved the same (duration=17.600000, nb_frames=167, --fps 10, same contention).

artifact path
screenshot during recording /private/tmp/apex-2837-shots/during-recording.png
screenshot during recording, after swipe /private/tmp/apex-2837-shots/during-recording-2.png
screenshot after recordStop /private/tmp/apex-2837-shots/after-record-stop.png
exported recording /private/tmp/apex-2837-shots/apex-2837-runner-20s.mp4
full transcript /private/tmp/apex-2837-shots/rec-run2.out

The three PNGs are on the machine that ran this; gh does not upload local images into a
non-interactive comment, so they are identified rather than embedded — say the word and I will get
them to you another way. Both during frames are byte-identical to each other (the surface happened
not to move between them); the after frame differs, and the app is back at the top of the list
there because of the mid-recording return swipe, so the recording window really did contain two
gestures and three screenshots of a live, responding app:

file bytes sha256 (first 16) pixels
during-recording.png 91 130 520fcd4a83f24843 402×874
during-recording-2.png 91 130 520fcd4a83f24843 402×874
after-record-stop.png 87 833 2a442d337a7c04be 402×874
apex-2837-runner-20s.mp4 2 178 722 398158667a073bb8 1206×2622

One limitation, stated rather than papered over. agent-device screenshot on an iOS Simulator is simctl io screenshot-backed with the runner only as a fallback (packages/platform-apple/src/core/screenshot.ts:154-:177), and there is no flag that forces the runner capture there — the runner log shows zero command=screenshot entries for the three CLI screenshots and exactly three for the direct HTTP ones. So the CLI screenshot proves "screenshots still work while recording", but it is not the thing crossing the gate. The captures that do cross it are the runner's own screenshot command (3/3 ok mid-recording) and the gesture hops, all above.

2. (a) Second deviation added to the sign-off list

Agreed and done. The body now lists two items: the 1 s floor, and the in-flight gate — it skips every tick dispatched during a hop, not only ticks after an abandoned one, because a healthy hop that finishes inside its slice marks nothing. Measured cost is in the body: 15-20% fewer frames than pre-fix under command load (n=1/leg), 91% of the requested rate in the 20 s run above. Say the word if you want the skipped-tick share disclosed per record stop rather than documented; that is a new response field and I left it out of scope here.

3. (b) The abandoned term in the gate is dead — removed

Confirmed dead in production, and the two counters make that provable rather than incidental:

  • enqueueMainThreadWorkLocked takes mainThreadWorkLock and does mainThreadWorkInFlightCount += 1 before dispatching (RunnerTests+MainThreadWork.swift:115).
  • The block decrements mainThreadWorkInFlightCount and, in the same lock hold, decrements abandonedMainThreadWorkCount if it had been marked (:122-:128).
  • The only place that ever raises the abandoned count is the waiter's timeout path, and it does so while the block has not yet reached that lock hold — so at least this block is still counted in flight (:160-:166).

So abandonedMainThreadWorkCount > 0 implies mainThreadWorkInFlightCount > 0 at every observable moment under the lock; the contrapositive is the reviewer's statement. Every writer that sets the abandoned count to a fabricated value is inside #if AGENT_DEVICE_RUNNER_UNIT_TESTS, so no production state was decided by the term. It also duplicated the occupancy question instead of answering it once, which is the thing AGENTS.md refuses ("do not add guards that reconstruct another source of truth").

Removed in 40a7bac25f, test-first. Red, observed with the term still in the guard:

RunnerTests+MainThreadWork.swift:415: error: -[AgentDeviceRunnerUITests.RunnerTests
testRunMainThreadWorkIfIdleDeclinesOnlyForInFlightWork] : XCTAssertEqual failed: ("nil") is not
equal to ("Optional(true)") - an abandoned mark over an idle main thread is no reason to decline a frame

testRunMainThreadWorkIfIdleDeclinesOnlyForInFlightWork pins the gate to the one counter that names main-thread occupancy: it marks work abandoned over an idle main thread — a state only a test can build, which is exactly why the term was not load-bearing — and requires the optional tick to run on main anyway. The existing testRunMainThreadWorkIfIdleDeclinesWhileOtherWorkIsInFlight still pins the decline side, and testRecordingPersistentWedgeKeepsOneCaptureQueuedOnMain still pins that a wedged capture keeps declining ticks (its block is still in flight, so the surviving term covers it).

4. (c) Smaller designs considered

Two, both rejected, neither cheaper:

  • Keep the tick on main.sync. DispatchQueue.main.sync has no timeout, so the 1 s bound fix(ios-runner): bound recorder frame capture through runMainThreadWork #2801 asks for cannot be expressed on it at all; bounding it means a wait-with-deadline plus a ledger of blocks that outlived their slice, i.e. a second copy of runMainThreadWork inside the recorder, with its own occupancy bookkeeping in the same process. It is also the bug being fixed: the tick is unretractable once enqueued, so a wedged main thread blocks the recorder's own queue, and record stop — which joins that queue — stops answering. Going through runMainThreadWorkIfIdle means the recorder reads the runner's existing abandonment ledger and watchdog instead of maintaining one; the main.sync inside captureResolvedAppScreen is now the inline branch (RunnerAppScreenCapture.swift:94), since the hop already landed on main.
  • Gate on abandonment only (hasAbandonedMainThreadWork()). Abandonment is marked only after a slice expired, so this admits a tick during every hop that is merely busy but healthy — which is the normal case, since a 30 s slice rarely expires — and the tick still queues behind it and runs mid-command, the reported symptom. Worse, that tick then routinely becomes abandoned work itself, which stamps runnerMainThreadBusy on responses, makes the next command answer RUNNER_BUSY, and makes capture plans skip XCTest-backed tiers: the recorder would change command behavior. testRecordingFrameNeverQueuesBehindACommandsMainThreadWork is the guard against that — it samples currentMainThreadBusyState(), hasAbandonedMainThreadWork() and shouldSkipSnapshotBackendForAbandonedMainThreadWork(.recursiveTree) off main for the whole duration of two 1.3 s hops and requires all three to stay false. mainThreadWorkInFlightCount is the only quantity that is true exactly when a block is enqueued-or-running on main, so it is what the gate reads.

5. Smoke Tests: the marker grep is not what failed

Checked before touching anything, because the diagnosis differs. In the last CI run at f1b04ec98b (ios.yml run 35888086480), the grep passed on both attempts — the marker is in both logs:

attempt 1  2026-09-23T17:04:18  AgentDeviceRunnerUITests-Runner[8841:30986] AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED
attempt 2  2026-09-23T17:30:37  AgentDeviceRunnerUITests-Runner[10778:36673] AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED

and neither log contains Optional observation ended the runner test before its wait completed. Both attempts failed later, in Run fixture-backed iOS simulator E2E (test/integration/smoke-ios-simulator.test.ts):

  • attempt 1: live-snapshot-depth-frontier.ts:126 — "regular depth-1 snapshot must disclose the Simulator AX bridge evidence gap", with snapshotDiagnostics.warning: "ios snapshots are slow in this run: p95 1789ms over 56 captures". That is the known main-side flake (b3b58caac1's own iOS Smoke Tests also failed, at testAbandonedTreeCaptureSkipsQuerySweepAndHonorsWarmupExemption), fixed elsewhere; not touched here.
  • attempt 2: wait text Agent Device Tester timed out at step 7 with reason: "wait_capture_stalled", captureStalled: true, readableCaptures: 0, captures: 1, one poll of 10 058 ms ending deadline — a stalled snapshot capture on a contended host, again after the grep step.

The sibling PR's execute(command:)-off-main class does not apply to this branch: execute(command:) does assert dispatchPrecondition(condition: .onQueue(.main)) (RunnerTests+CommandExecution.swift:840), but the only call site in the files this branch touches is RunnerTests+RecordingTests.swift:16, inside a test body that runs on main, and none of the 11 tests this branch adds calls it — the new recording tests drive startRecording(recorder, capture:) and runMainThreadWork* directly, from named background queues, and never route a command. So there was nothing to fix the same way, and I did not paper over a grep.

At the new head 40a7bac25f the whole lane is green, including the step that failed before:

iOS / Smoke Tests        pass   ✔ live iOS simulator fixture E2E (232732.248042ms)
Android / Smoke Tests    pass
Linux / Smoke Tests      pass
macOS / Smoke Tests      pass
CI: Lint & Format, Repo Guards, Typecheck & Package, Coverage, Integration Tests,
    Compatibility & Provenance, command-docs-gate   all pass

i.e. the two flaky-looking failures at f1b04ec98b did not recur on the rebased sources, and no execute(command:)-off-main fix was needed here.

Local gates at this head, for the record: pnpm format (format:check clean), pnpm lint exit 0, pnpm typecheck exit 0, pnpm check:xctest-selection (293 declared methods … 0 reachable by no lane), pnpm check:affected --run (all runnable checks passed; swift-runner-* / replay-* GitHub-authoritative), plus the 19-test iOS Simulator XCTest run above. The live-snapshot-depth-frontier.ts:126 failure above is the known main-side flake fixed elsewhere — not touched here.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 40a7bac. This is ready to merge.

The timeout-boundary work-return fix and the bound on recorder frame capture both look correct now, and the earlier findings from f1b04ec are resolved.

CI is green, 20 checks and 0 not passing. iOS Smoke exercises the changed runMainThreadWork hop on every runner command. The recorder route is not covered by CI, since simulator record goes through simctl; the live run is what covers that path.

I did not rebuild the runner or re-run the live recorder session myself. I checked that the artifacts already on hand, the mp4 frame count and duration, the PNGs, and the transcript's lack of ABANDONED markers, match what the author described. The runner-side screenshots that went through the gate were driven over raw runner HTTP; on a simulator, the CLI's own screenshot command goes through simctl and does not reach that gate, so the changed recorder path is only exercised in production on runner-recorded targets, meaning physical devices, which were not tested here. I also did not run the Swift unit tests locally.

There's no blocker left in this delta. What's still open is your own sign-off on the two deviations you called out: the 1 second per-frame floor, and the in-flight gate skipping every tick during a hop.

Not blocking: could testRunMainThreadWorkIfIdleDeclinesOnlyForInFlightWork (apple/runner/AgentDeviceRunnerUITests/RunnerTests+MainThreadWork.swift#L384) assert the invariant across a real timeout and drain instead of setting abandonedMainThreadWorkCount to 1 with nothing in flight, given the commit message says production can't reach that state, or is pinning the guard's shape enough here — either way is fine to take or leave.

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

Copy link
Copy Markdown
Member Author

Commit 40a7bac now conflicts with main. The code verdict from the earlier review stays clean. I removed ready-for-human until the conflict is fixed. Please rebase onto main and resolve the conflict. The label can come back after the rebased head is checked.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee force-pushed the fix/2782-main-thread-work-late-finish branch from 40a7bac to 9fa57aa Compare September 24, 2026 06:59
… boundary

runMainThreadWork threw MAIN_THREAD_TIMEOUT whenever its wait timed out,
even when the main-queue block finished between the wait and the lock
and had already stored its result. A tap that happened was then reported
as a timeout, and a daemon retry could repeat the gesture.

Work that is finished under the lock now returns or rethrows its stored
result like work that finished in time, and is not counted as abandoned.
Only work still unfinished under the lock is abandoned and throws the
timeout error. No timeout value changes.

Closes #2782
A recording tick captured its frame with an unbounded DispatchQueue.main.sync
on the recorder queue. While the main thread was wedged the first tick
blocked the recorder queue, and the busy and wedged accounting never saw it.

Each tick after the bootstrap frame now hops to main through
runMainThreadWork with a timeout of one frame interval, on iOS and macOS.
A capture that times out drops the frame and counts as abandoned work; its
late result is discarded and never appended. While any abandoned main-thread
work is outstanding the tick is skipped, so the recorder keeps at most one
capture pending on main. The bootstrap frame is still taken on the calling
thread, and screenshots keep their on-main capture path.

Closes #2801
… are using

A recorder tick is optional main-thread work. It now hops to main only
while no dispatched main-thread work is in flight or abandoned, checked
and enqueued under the same lock every dispatch enqueues under, so it
never queues behind a command's hop and never raises the occupancy that
the busy gate, the response stamp and the snapshot tier skip read.

The per-frame bound is a fixed one-second capture timeout instead of the
frame interval: a capture slower than the interval lowers the frame rate,
and only a capture that slow counts as abandoned main-thread work.
…d gate

Production marks a dispatch abandoned only while its block is still running on main, so
mainThreadWorkInFlightCount == 0 already implies abandonedMainThreadWorkCount == 0 and the second
term in runMainThreadWorkIfIdle's guard could never decide anything. Drop it and pin the gate to the
one counter that names main-thread occupancy.
@thymikee
thymikee force-pushed the fix/2782-main-thread-work-late-finish branch from 9fa57aa to 3497826 Compare September 24, 2026 07:23
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto origin/main faa69ff784. New head: 349782642c.

  • Conflict / rebase (06:29 note): The rebase was clean. Main moved by fix(ios-runner): keep snapshot-plan target state on main and bound the query sweep by its slice #2836 and fix(ios): never launch a not-running session app from an observation #2852. They touch RunnerTests+CommandDispatch.swift and RunnerTests.swift, and they add runMainThreadWork callers. The runMainThreadWork signature did not change, and the gate does not overlap with that work.
  • testRunMainThreadWorkIfIdleDeclinesOnlyForInFlightWork (22:36, non-blocking): Taken. I replaced it with testRunMainThreadWorkIfIdleDeclinesAbandonedWorkUntilItDrains. The new test causes a real timeout. While the abandoned block still holds main, it asserts abandonedMainThreadWorkCount == 1 and mainThreadWorkInFlightCount == 1, so abandoned work stays counted in flight. The optional tick returns nil and does not dispatch. After the drain, the tick runs on main. The test no longer depends on a state that production cannot reach.
  • Deviations (1 s floor, in-flight gate): These need your sign-off. I did not change them.
  • CI: Smoke Tests was still running on the old head 9fa57aa93b and did not fail. The new head starts a new run.

Validation at 349782642c:

  • pnpm check:affected --run: all runnable checks passed.
  • pnpm check:xctest-selection: 0 methods reachable by no lane.
  • iOS 26.2 runner XCTest on a fresh simulator, deleted after the run. The run covered all tests in MainThreadWorkTests, RecordingTests and SnapshotCapturePlanOccupancyTests, plus testRecordStartThrowsTheCaptureRefusalItReceived. Result: Executed 21 tests, with 0 failures.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 3497826. The code looks correct, and the rebase keeps the earlier clean patch: the production Swift matches 40a7bac, and the new upstream runMainThreadWork callers from #2836 and #2852 are counted in flight, so the recorder gate still covers them.

The earlier 20 s runner-recorder run on a simulator still covers this head, because the production code did not change. The physical-device recorder route has not been run. I did not run the Swift tests; the claim that the new abandoned-work test goes red without the in-flight guard comes from reading the code.

Smoke Tests and Coverage were still running. Smoke Tests exercise runMainThreadWork on every runner command, so a failure there would likely be related unless its log points elsewhere. Coverage runs only JS/TS suites, which this PR does not touch.

Next step: the two deviations you called out (the 1 s per-frame floor and skipping ticks while a command hop is in flight) need a maintainer decision.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit 9dd5379 into main Sep 24, 2026
20 of 21 checks passed
@thymikee
thymikee deleted the fix/2782-main-thread-work-late-finish branch September 24, 2026 08:47
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

1 participant