Skip to content

Skip an inherited terminal surface the registry no longer owns - #8656

Merged
austinywang merged 2 commits into
manaflow-ai:mainfrom
ejc3:fix/skip-freed-inherited-surface
Aug 4, 2026
Merged

austinywang merged 2 commits into
manaflow-ai:mainfrom
ejc3:fix/skip-freed-inherited-surface

Conversation

@ejc3

@ejc3 ejc3 commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Workspace.inheritedTerminalConfig treats a non-nil surface.surface as proof the native surface is alive. It is not. Teardown unregisters the runtime surface and only then frees it, so a wrapper still holding the pointer after an out-of-band free hands its caller freed memory. libghostty locks an os_unfair_lock inside that freed allocation, the kernel detects lock corruption, and the process is SIGKILLed.

The registry owns exactly the pointers that have not been freed, which makes it the liveness oracle a nil-check cannot be — and TerminalSurface already has the accessor built for handing a pointer to Ghostty C APIs: liveSurfaceForGhosttyAccess(reason:). It returns the pointer only while the registry records this surface as its owner and the pointer probes live; otherwise it quarantines the wrapper, so the caller sees nil instead of freed memory and runtimeSurfaceGeneration advances exactly as on a normal teardown, invalidating pointer-backed caches. inheritedTerminalConfig now reads through that accessor, so a stale candidate is skipped like a torn-down one. Requiring the owner to be this specific surface also covers pointer reuse: a freed address recycled to a new surface still has an owner, just not this one, and inheriting through it would read the wrong surface's config.

I want to be exact about what I can show, because it changed while this was open.

I first hit this as a host kill in WorkspaceSplitWorkingDirectoryTests, and it reproduces on 4a69f421. Running that suite alone there, testNewTerminalSplitSkipsFreedInheritedSurfacePointer and testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer each start and never finish, the host is killed twice, no test completes, and the crash report names the path:

_os_unfair_lock_corruption_abort
_os_unfair_lock_lock_slow
ghostty_surface_inherited_config
Workspace.inheritedTerminalConfig(preferredPanelId:inPane:)
Workspace.newTerminalSurfaceLocal(...)
WorkspaceSplitWorkingDirectoryTests.testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer()

On this branch's base it does not reproduce. I ran the suite in one worktree, with one warm derived-data path and the same selectors, once with this change and once with only its files reverted. Both runs executed three tests and killed nothing. #8543 explains it: that change reworked inheritedTerminalConfig to read fontSizeLineageSnapshot() rather than calling ghostty_surface_inherited_config through the wrapper, so the dereference that crashed is no longer on that path.

So this is hardening and not a repro-backed fix, and the two tests do not cover it here, since they pass without it. The same function still dereferences surface.surface in its other branch, which is what routing it through liveSurfaceForGhosttyAccess protects, but I do not yet have a test that fails without this change on this base. Judge the guard on its own merits rather than on the crash above, and I will add that test before asking for a merge.

Summary by CodeRabbit

  • Bug Fixes
    • Improved terminal configuration inheritance to avoid relying on potentially unavailable or stale terminal surfaces.
    • If the preferred live terminal surface can’t be accessed, the app now safely continues the inheritance process and falls back to the best available font-size lineage.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4d9efa22-cb95-469d-915e-62b6ad7cb26d

📥 Commits

Reviewing files that changed from the base of the PR and between d91f285 and bb8f926.

📒 Files selected for processing (1)
  • Sources/Workspace.swift

📝 Walkthrough

Walkthrough

inheritedTerminalConfig(...) now obtains a live terminal surface through liveSurfaceForGhosttyAccess(reason:). Unavailable candidates are skipped before terminal state access, allowing subsequent candidates or fallback font-size lineage logic to run.

Changes

Inherited terminal configuration

Layer / File(s) Summary
Live surface validation
Sources/Workspace.swift
inheritedTerminalConfig(...) replaces the raw surface pointer check with a live Ghostty surface lookup and skips candidates when no live wrapper is available.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • manaflow-ai/cmux#8543: Both changes update inherited-terminal configuration logic to use live or fallback terminal state.

Suggested reviewers: austinywang, lawrencecchen, azooz2003-bit

🚥 Pre-merge checks | ✅ 25
✅ Passed checks (25 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: skipping stale inherited terminal surfaces.
Description check ✅ Passed It covers the change and testing rationale, but omits the demo video, review trigger block, and checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PASS: The change stays inside @MainActor Workspace/TerminalSurface code and only swaps one main-actor surface accessor for another; no new Sendable/MainActor isolation debt is introduced.
Cmux Swift Blocking Runtime ✅ Passed The diff only swaps a pointer check for an existing @MainActor liveness accessor; it adds no semaphores, waits, sleeps, syncs, or new locks.
Cmux Browser Automation Off-Main ✅ Passed Diff only updates terminal-surface liveness in Workspace/TerminalSurface; no browser.* routing, WebKit/AppKit waits, or socket-worker policy changes.
Cmux Expensive Synchronous Load ✅ Passed Diff only swaps a terminal-surface liveness check; no agent-history loader, JSON/JSONL parse, or broad disk scan was added to an interactive path.
Cmux Cache Substitution Correctness ✅ Passed PASS: the call site uses liveSurfaceForGhosttyAccess, which checks registry ownership and pointer liveness; stale candidates are skipped with font-size fallback.
Cmux No Hacky Sleeps ✅ Passed Swift-only liveness-check change; no sleeps, timers, polling, or delayed dispatch were added in the touched files.
Cmux Algorithmic Complexity ✅ Passed The diff only swaps a raw pointer read for an O(1) liveness check inside an existing candidate loop; no new nested scans or repeated sorting were introduced.
Cmux Swift Concurrency ✅ Passed Diff only swaps a synchronous surface accessor; no new DispatchQueue, Task, Combine, or completion-handler async patterns were added.
Cmux Swift @Concurrent ✅ Passed The PR only changes sync inherited-terminal config lookup; no @concurrent/nonisolated-async annotation issues or heavy async UI call sites were introduced.
Cmux Swift Package Boundaries ✅ Passed The diff stays in app-target Ghostty/workspace glue: a private helper in Sources/Workspace.swift using TerminalSurface.liveSurfaceForGhosttyAccess, with no reusable domain logic or package-boun...
Cmux Swiftpm Lockfiles ✅ Passed Diff only changes Swift source files; no Package.swift, Package.resolved, .gitignore, or xcodeproj/workflow dependency files are touched, so the lockfile rule isn’t triggered.
Cmux Swift Logging ✅ Passed The diff only changes the surface accessor in Workspace.inheritedTerminalConfig; it adds no print/debugPrint/dump/NSLog or Logger changes.
Cmux User-Facing Error Privacy ✅ Passed The diff only swaps internal surface-liveness access and related comments; no user-facing errors, alerts, command output, or API error bodies were added.
Cmux Full Internationalization ✅ Passed PR only changes internal Swift logic/comments and an internal reason tag; no user-facing strings, catalogs, Info.plist, or web i18n files were added or edited.
Cmux Swiftui State Layout ✅ Passed Diff only swaps terminal-surface liveness access and removes an old helper; no new SwiftUI state/layout patterns or render-time mutation appear.
Cmux Architecture Rethink ✅ Passed Small correctness fix: it swaps in existing liveness/ownership validation and preserves the single font-lineage fallback, with no sleeps, locks, observers, or split ownership.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Only Workspace.inheritedTerminalConfig changed; no NSWindow/NSPanel/WindowGroup code or cmux identifier registration was added or altered.
Cmux Source Artifacts ✅ Passed Only source Swift files changed; no logs, caches, build output, or scratch/artifact directories were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PR swaps a removed production pointer accessor for an existing runtime helper with many real callers; no new test/debug seam was added.
Cmux No Ambient Global State ✅ Passed PASS: The diff only swaps an instance method call inside Workspace.inheritedTerminalConfig and removes a TerminalSurface instance property; no new file-scope funcs, globals, or singletons were added.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces a raw surface.surface != nil check in Workspace.inheritedTerminalConfig with a call to surface.liveSurfaceForGhosttyAccess(reason:) to guard against reading through a freed ghostty_surface_t pointer. The existing nil-check is not a liveness proof: teardown unregisters the surface from the registry before freeing it, leaving a window where surface.surface is non-nil but the allocation is gone; dereferencing it inside libghostty's os_unfair_lock gets the process SIGKILLed by the kernel for lock corruption.

  • liveSurfaceForGhosttyAccess validates registry ownership (registeredOwnerId == id), a pointer-liveness probe, and quarantines the stale wrapper — returning nil instead of a dangling pointer. This also covers ABA (pointer reuse), since a recycled address owned by a different surface fails the identity check.
  • The existing withExtendedLifetime((terminalPanel, surface)) on the success path still correctly keeps the TerminalSurface wrapper alive through all C calls once a valid pointer is returned.

Confidence Score: 5/5

The change is a targeted, well-scoped safety hardening of a single guard expression; no existing invariants are weakened and the accessor it routes through was already the established pattern for handing raw pointers to Ghostty C APIs.

The one-line guard replacement is self-contained, the called accessor (liveSurfaceForGhosttyAccess) is already used elsewhere in the codebase for the same purpose and has its own ownership + liveness checks, the withExtendedLifetime fence on the success path is unchanged, and the change introduces no new state, no new synchronization, and no new dependencies.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
Sources/Workspace.swift Routes inheritedTerminalConfig's surface access through liveSurfaceForGhosttyAccess(reason:) instead of a raw nil-check; the accessor validates registry ownership, owner identity, and pointer liveness before returning the pointer, closing a use-after-free path that could SIGKILL the process.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[inheritedTerminalConfig loop\nover candidates] --> B[let surface = terminalPanel.surface\nlet sourceFontSizeLineage = snapshot]
    B --> C{liveSurfaceForGhosttyAccess\nreason: inheritedTerminalConfig}
    C -->|hasLiveSurface check fails| D[nil returned]
    C -->|registeredOwnerId != id\nABA / pointer reuse| E[quarantine wrapper\nset surface = nil\nunregister from registry\nmarkPortalLifecycleClosed]
    E --> D
    C -->|GhosttySurfaceRuntimeProbe\nfails liveness| E
    C -->|all checks pass| F[sourceSurface returned]
    D --> G{sourceFontSizeLineage\navailable?}
    G -->|yes| H[return partial config\nwith fontSizeLineage only]
    G -->|no| I[continue to next candidate]
    F --> J[cmuxInheritedSurfaceConfig\nGhostty C API calls]
    J --> K[withExtendedLifetime\nkeep wrapper alive\nthrough C calls]
    K --> L[return full config]
Loading

Reviews (3): Last reviewed commit: "Use liveSurfaceForGhosttyAccess for the ..." | Re-trigger Greptile

Comment thread Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift Outdated
@ejc3
ejc3 force-pushed the fix/skip-freed-inherited-surface branch 2 times, most recently from d91f285 to bb8f926 Compare July 25, 2026 07:51
ejc3 added 2 commits July 30, 2026 23:45
WorkspaceSplitWorkingDirectoryTests has two tests named for what they are meant to
prove — testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer and its split-path
twin — and both currently prove the opposite by killing the test host:

    _os_unfair_lock_corruption_abort
    _os_unfair_lock_lock_slow
    ghostty_surface_inherited_config
    Workspace.inheritedTerminalConfig(preferredPanelId:inPane:)
    Workspace.newTerminalSurfaceLocal(...)  /  Workspace.newTerminalSplitLocal(...)

inheritedTerminalConfig guarded on `surface.surface != nil`, but a non-nil wrapper
pointer is not proof the native surface is alive. Teardown unregisters the runtime
surface and only then frees it, so a wrapper still holding the pointer after an
out-of-band free hands its caller freed memory. libghostty then locks an
os_unfair_lock inside that freed allocation, the kernel detects lock corruption, and
the process is SIGKILLed — so an unrelated suite sharing the test host dies too, and
its verdict is lost with it.

The registry owns exactly the pointers that have not been freed, which makes it the
liveness oracle a nil-check cannot be, and TerminalSurfaceRegistering already exposes
runtimeSurfaceOwnerId. TerminalSurface gains liveRuntimeSurface, which returns the
pointer only while the registry still owns it and otherwise clears the wrapper, so
every caller that reads through it sees nil instead of freed memory. Clearing goes
through the existing setter, so it advances runtimeSurfaceGeneration exactly as a
normal teardown does and pointer-backed caches invalidate for the same reason.

inheritedTerminalConfig now reads liveRuntimeSurface, so a stale candidate is skipped
like a torn-down one. That is also the behaviour the tests' second assertion asks for:
XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be
quarantined").
Review pointed out that the new liveRuntimeSurface accessor checked only that
the registry owner was non-nil, so a recycled pointer owned by a different
surface would pass. liveSurfaceForGhosttyAccess already does the full check —
owner id equality plus an allocation-liveness probe — and quarantines a stale
wrapper the same way. Delete the weaker duplicate and call the existing
accessor from inheritedTerminalConfig; a stale candidate is still skipped in
favor of the font-lineage fallback.
@ejc3
ejc3 force-pushed the fix/skip-freed-inherited-surface branch from bb8f926 to 8a24bbf Compare July 31, 2026 06:50
@austinywang

Copy link
Copy Markdown
Contributor

Maintainer verification (2026-08-04): this PR comes from a contributor fork, and repository policy forbids pushing maintainer changes to forks. I reproduced the exact runtime diff on the origin-hosted fix/skip-freed-inherited-surface ref, merged current main, and strengthened the existing WorkspaceSplitWorkingDirectoryTests so a pending font-configuration reload keeps cached lineage from pre-quarantining the stale pointer. That makes inheritedTerminalConfig itself the decisive liveness boundary. Run 30890867419 executed all 3 class tests with 0 failures; both stale-pointer creation paths passed. A merge-tree check against the latest origin/main is clean. I will land the regression-only commit from the origin ref immediately after this fork PR so the proof remains in-tree without violating the no-fork-push policy.

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.

2 participants