Skip to content

fix: stale notification blocks + unified surface teardown (mechanism 2) - #258

Merged
arzafran merged 3 commits into
mainfrom
fix/notification-teardown-race
Aug 5, 2026
Merged

fix: stale notification blocks + unified surface teardown (mechanism 2)#258
arzafran merged 3 commits into
mainfrom
fix/notification-teardown-race

Conversation

@arzafran

@arzafran arzafran commented Aug 5, 2026

Copy link
Copy Markdown
Member

What this does

Second half of the teardown-race fix. #254 closed the IO-thread callback-context path; the re-land gate (#257) then caught a second mechanism, root-caused from 7 CI minidumps at address level: 6/7 crashes at one byte-identical call site, main thread, ARC op on reused memory, delivered via a queue:.main NotificationCenter block — one fault address literally contained ASCII 'ndowObse' from the reused windowObserver memory. Foundation's documented gotcha: removeObserver does not cancel a block already handed to the main OperationQueue, so during rapid window teardown a stale block runs against reused memory.

Fixes:

  1. GhosttySurfaceScrollView: windowObserverGeneration captured by each of the 5 observer blocks at registration, checked on entry — a stale instance's enqueued block becomes a no-op. The didBecomeKey occlusion-heal (from the blank-window fix) is unaffected for live instances.
  2. TerminalSurface: teardownSurface()/deinit unified into one idempotent performSurfaceTeardown (audit N12 — promoted from LOW to load-bearing by the crash evidence), which also fixes a latent reviveDescriptor double-close. Three other teardown-adjacent sites deliberately stay separate — each one's divergence is load-bearing for a named regression test (documented inline).
  3. Tripwire test iterations 20 → 28 (no deterministic seam exists for the enqueue race without new test-only surface; stated per policy).

Local repro: 8/8 clean before AND after (the race never fired locally — visible windows, different timing). The proof is #257's 3×-green gate on CI's always-occluded environment, which re-runs with this fix included immediately after this merges.

Test plan

NotificationCenter.removeObserver does not cancel a block already handed
off to the main OperationQueue. During rapid window create/close,
viewDidMoveToWindow can tear down and re-register windowObservers for a
new window while a block registered against the previous window is still
sitting in the main queue; when it runs, it now no-ops instead of acting
on stale state.

Each addObserver(queue: .main) block in viewDidMoveToWindow now captures
the generation live at its own registration and checks it against the
instance's current generation before doing anything. Live (current)
blocks always match, so #241 occlusion-heal semantics on didBecomeKey /
didChangeOcclusionState are unaffected.
teardownSurface() and deinit each had their own copy of the
snapshot-callback-context / snapshot-tap-context / nil-surface /
free-on-deferred-Task logic. Unify them into a single
performSurfaceTeardown(reason:) that snapshots and nils every piece of
handoff state a second call could act on (surface, surfaceCallbackContext,
outputTapContext, reviveDescriptor), so calling it from either site can
never double-free or double-release: a second call sees surface == nil
and no-ops.

Also extends the reviveDescriptor fd leak-guard (previously deinit-only)
to teardownSurface(), now nil-ing it after close() so a repeat call is
safe.

liveSurfaceForGhosttyAccess(reason:), releaseSurfaceForTesting(), and
replaceSurfaceWithFreedPointerForTesting() are intentionally not folded
in — each has real semantic differences (documented inline) that would be
lost by unification: not freeing a possibly-reowned pointer, keeping
portalLifecycleState live for a recreate-after-release test fixture, and
deliberately leaving a dangling surface pointer to simulate an
out-of-band free.
Same rapid create/close shape covers the windowObserverGeneration guard
in GhosttySurfaceScrollView (a second, distinct teardown-race mechanism
from the callback-context race this test already targets). Making the
exact NotificationCenter enqueue race deterministic would need a
dedicated seam into GhosttySurfaceScrollView's private observer
bookkeeping that doesn't exist yet, so per policy this bumps the
existing tripwire's iteration count modestly instead of adding a new
seam-dependent test.
@arzafran
arzafran merged commit f7f9a90 into main Aug 5, 2026
10 checks 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