Skip to content

test: harden timing-sensitive unit tests for slow CI runners#128

Merged
arzafran merged 3 commits into
mainfrom
ci/harden-timing-tests
Jul 14, 2026
Merged

test: harden timing-sensitive unit tests for slow CI runners#128
arzafran merged 3 commits into
mainfrom
ci/harden-timing-tests

Conversation

@arzafran

Copy link
Copy Markdown
Member

What this fixes

CI has been randomly failing on a rotating set of about a dozen unit tests. They pass every time locally on a fast machine, but fail on the slower shared macos-26 GitHub runners, and which tests fail shifts from run to run. That's the signature of tests racing against a fixed clock instead of waiting for the thing they actually care about.

The one real bug

One of these wasn't just slow, it was actually broken: CLINotifyProcessIntegrationTests.testSSHBootstrapStartupCommandPassesRemoteInstallScriptAsSingleSSHCommand was showing up in CI as an "unexpected" failure (basically a crash), not a normal assertion failure.

Here's what was happening: the test spawns a fake SSH process, waits up to 5 seconds for a background thread to notice the process closed its socket connection, then immediately reads a log file that a different process wrote to. Under a full serial suite run, that background thread's turn to run on the CPU can get delayed by GCD scheduling, so the 5 second wait would time out. Then the test would barrel ahead and try to read the log file anyway, and since it might not exist yet, that raised an uncaught error - which is what CI was reporting as "1 unexpected" failure.

The fix: give that wait more headroom for CI, and instead of assuming the log file is ready the instant the wait resolves, poll for the file to actually have its expected content before reading it. Now a slow CI runner gets one clear, readable assertion failure instead of a confusing crash-like error, and normally it just passes.

Everything else

The other ~8 tests were legitimately just too impatient: a single fixed RunLoop spin of 0.05-2 seconds, or a tight expectation timeout, checked once and then asserted on immediately. On a loaded CI box that's not always enough time for the async work being tested to finish.

Fixed the same way throughout: poll for the actual condition to become true (with a generous timeout), or bump a fixed timeout to a number with real headroom, following the same pattern already used elsewhere in this test suite for this exact problem. None of the assertions themselves changed, only how long and how we wait before checking them. Fast machines won't notice a difference since the poll returns as soon as the condition is true.

Testing

  • Ran the 12 affected tests individually: all pass
  • Ran the full unit test suite serially (1446 tests): all pass, 0 failures
  • No production code touched, only test files

arzafran added 3 commits July 14, 2026 09:57
The test waited on a fixed 5s XCTestExpectation for the mock socket
server to observe the SSH bootstrap client closing its connection,
then immediately read a log file written by a separate process. Under
a full serial CI run, the accept-loop thread's GCD scheduling can
legitimately lag behind the subprocess round-trip, so the expectation
timeout fired first and the log read threw an uncaught file-not-found
error on top of it (the '1 unexpected' failure in CI). Give the
expectation CI-safe headroom and poll for the log file actually having
its expected content instead of reading it the instant the expectation
resolves, so a scheduling delay produces one clear assertion failure
instead of a cascading crash-like error.
These tests pass locally on fast machines but flake on slower shared
CI runners (macos-26 GitHub runners), where fixed-duration waits
(single RunLoop spins, tight XCTestExpectation timeouts) starve under
a heavily contended scheduler running the full serial suite.

Replace fixed sleeps with condition-based polling where a real
completion signal exists, and bump fixed timeouts to generous
CI-safe ceilings (matching this repo's existing hardening precedent)
where the wait is on a real subprocess or background-queue round trip.
Fast machines still finish immediately since the poll returns as soon
as the condition is true; only a genuinely slow runner needs the extra
headroom.

Covers:
- AppDelegateShortcutRoutingTests: poll for focus repair after sendEvent
  instead of a single 0.05s spin
- BrowserDeveloperToolsVisibilityPersistenceTests: poll for the queued
  hide transition to actually settle instead of a fixed 2s spin
- GhosttySurfaceOverlayTests: poll for overlay mount/dismiss instead of
  fixed RunLoop spins (drop-zone hide, find-overlay mount and dismiss)
- NotificationDockBadgeTests: give the real subprocess spawn and the
  main-queue drain more CI headroom
- TabManagerPullRequestProbeTests: give the git subprocess + probe
  round trip the same headroom already used by its sibling test
- WorkspaceTerminalFocusRecoveryTests: give the focus-reassert wait
  more than a hair-trigger 1s ceiling
The 8 CI-only failures on loaded macos-26 runners are subprocess/
scheduling starvation: GitMetadataProber's initialWorkspaceGitMetadata
Snapshot runs git subprocesses and, for non-main/master branches, a
synchronous gh call with a 5.0s timeout before the snapshot is applied.
On CI, gh is unauthenticated and can block to the full timeout, and git
subprocesses contend with the rest of the serial suite.

Scale wait budgets by 4x under CI (GitHub Actions always sets CI=true;
local runs stay at 1x):
- TabManagerUnitTests.swift: shared waitForCondition helper now scales
  its timeout internally, covering all 5 TabManagerPullRequestProbeTests
  failures (default 3.0s and explicit 12.0s call sites alike).
- AppDelegateShortcutRoutingTests.swift: scale the two fixed poll
  deadlines in testWindowSendEventRepairsFocusedTerminalSearchTyping
  AfterResponderDrift.
- WorkspaceRemoteConnectionTests.swift: scale the subprocess/mock-socket
  timeouts in testSSHBootstrapStartupCommandPassesRemoteInstallScript
  AsSingleSSHCommand.
- TerminalAndGhosttyTests.swift: replace the fixed 0.3s RunLoop spin in
  testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift with two
  condition-polling loops (layout shift, then the separately-scheduled
  external geometry sync settling), each CI-scaled.

Also stub gh on PATH for the duration of each TabManagerPullRequestProbeTests
test (setUp/tearDown), so these fixtures never depend on a real gh binary
or network access, mirroring the existing PATH-stub pattern used by
shell-integration tests. Verified the stub actually intercepts a real
gh pr list call (added and removed a temporary probe test) before
landing it.

No production code changed; no assertions weakened.
@arzafran
arzafran merged commit a0f7c25 into main Jul 14, 2026
4 of 7 checks passed
@arzafran
arzafran deleted the ci/harden-timing-tests branch July 20, 2026 15:30
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