Skip to content

Release terminal IO callback userdata only after the native surface free - #8050

Closed
ejc3 wants to merge 2 commits into
manaflow-ai:mainfrom
ejc3:tee-callback-lifetime
Closed

ejc3 wants to merge 2 commits into
manaflow-ai:mainfrom
ejc3:tee-callback-lifetime

Conversation

@ejc3

@ejc3 ejc3 commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Terminal surfaces could crash the process during teardown: the ghostty io-reader thread delivers every PTY output chunk through a retained C callback (the byte tee, and in MANUAL mode the io_write box), and that thread only stops when ghostty_surface_free joins it. Three teardown paths defer the free to the teardown coordinator's background worker but released the callback userdata immediately — so for the window until the worker ran (seconds on a loaded machine), the reader thread invoked the tee callback through a dangling Unmanaged pointer. Under load with surfaces churning (heavy test runs, many panes closing), that is a use-after-free crash; a run of unit-test gates showed nine test-host deaths from this in one evening.

The fix makes the ordering structural instead of hopeful: the teardown request now carries the callback context, the manual-IO context, and the tee lease, and the coordinator releases all three only after freeSurface returns. The free is the happens-before edge that joins the io threads, so no callback can observe released userdata — no null checks, no narrowing of the race.

Tests pin the invariant deterministically rather than by stress: a recording tee lease and an order recorder assert the release happens strictly after the native free on every teardown path (production deinit, the DEBUG override-free branch, and agent hibernation). On the unfixed code they fail every run with the observed order reversed.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes a use-after-free during terminal teardown by keeping IO callback userdata alive until the native surface free completes. Prevents crashes under load by releasing the callback context, MANUAL I/O write box, and PTY byte-tee lease only after ghostty_surface_free joins the IO threads.

  • Bug Fixes
    • Transport manualIOContext and PTY byte-tee lease through TerminalSurfaceRuntimeTeardownRequest; coordinator releases callback context → manual IO context → tee lease on the main actor only after freeSurface returns, eliminating dangling Unmanaged pointers for tee and io_write_cb.
    • Added deterministic tests that assert release happens strictly after the native free across all deferred teardown paths (deinit, teardownSurface, agent hibernation).

Written for commit 5f54f2b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved terminal teardown handling to keep callback resources alive until native surface cleanup completes.
    • Prevented premature release of manual I/O and PTY tee resources during surface shutdown, hibernation, and deinitialization.
  • Tests

    • Added coverage verifying resource lifetime and teardown ordering across terminal shutdown scenarios.

ejc3 added 2 commits July 14, 2026 01:00
…tive free

The ghostty PTY tee callback fires on the io-reader thread for every output
chunk until ghostty_surface_free joins that thread, and the MANUAL-mode
io_write_cb fires on the io thread the same way. The retained callback
userdata must outlive the native free. These tests fail today: every
teardown path that defers the free to the runtime teardown coordinator
(deinit, and the override-free paths of teardownSurface and agent-hibernation
suspend) releases the tee lease and manual IO context immediately, leaving a
window where the io-reader thread dereferences freed userdata.
…native surface free

ghostty's io-reader thread calls the PTY tee callback for every output chunk,
and the io thread calls the MANUAL-mode io_write_cb, right up until
ghostty_surface_free joins those threads. The teardown paths that defer the
free to the runtime teardown coordinator (deinit, and the override-free paths
of teardownSurface and agent-hibernation suspend) released the tee lease and
manual IO context immediately, so until the coordinator's worker ran the
free — seconds later under load — the reader thread could fire the tee
callback into freed userdata. That use-after-free killed unit-test app hosts
mid-suite and can take down the app on surface close.

Transport the tee lease and manual IO context through the teardown request,
exactly like the surface callback context, and release all three on the main
actor only after freeSurface returns. The free is the happens-before edge
that joins the IO threads, so a callback can never observe released userdata.
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown

@ejc3 is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 14, 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

Run ID: 9f6dfdab-0f45-41eb-aaab-052010693987

📥 Commits

Reviewing files that changed from the base of the PR and between 998e7fb and 5f54f2b.

📒 Files selected for processing (7)
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
  • Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingTerminalByteTeeLease.swift
  • Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift
  • Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift

📝 Walkthrough

Walkthrough

TerminalSurface teardown now transports manual I/O and PTY tee resources through the runtime teardown coordinator, which releases them after native surface freeing. New tests verify callback lifetime and release ordering across teardown, hibernation, deinitialization, and coordinator-managed paths.

Changes

Runtime teardown callback lifetime

Layer / File(s) Summary
Transport and release retained teardown resources
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift, Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift
Teardown requests carry manual I/O and byte-tee resources, and the coordinator releases all retained callback resources after native surface freeing.
Route surface teardown resources through the coordinator
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift, Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
Direct teardown, hibernation suspension, and deinitialization defer manual I/O and tee resource release through the coordinator.
Validate callback lifetime and event ordering
Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift, Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingTerminalByteTeeLease.swift, Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift
Test helpers and lifecycle tests verify that native free precedes tee lease and manual I/O resource release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TerminalSurface
  participant TerminalSurfaceRuntimeTeardownCoordinator
  participant ghostty_surface_free
  participant CallbackResources
  TerminalSurface->>TerminalSurfaceRuntimeTeardownCoordinator: enqueueRuntimeTeardown(manualIOContext, byteTeeLease)
  TerminalSurfaceRuntimeTeardownCoordinator->>ghostty_surface_free: freeSurface()
  ghostty_surface_free-->>TerminalSurfaceRuntimeTeardownCoordinator: native free completes
  TerminalSurfaceRuntimeTeardownCoordinator->>CallbackResources: release retained resources
Loading

Suggested reviewers: lawrencecchen

🚥 Pre-merge checks | ✅ 24 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (24 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: delaying terminal IO callback userdata release until after native surface free.
Description check ✅ Passed The description explains what changed and why, includes testing rationale, and is mostly complete despite missing some template sections.
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 The PR adds explicit MainActor hops and @unchecked Sendable transports, without introducing new implicit MainActor models or background UI access.
Cmux Swift Blocking Runtime ✅ Passed PR adds no new blocking sync in runtime Swift; Task.sleep and production NSLocks predated it, and the new NSLock/continuation wait is test-only scaffolding.
Cmux Browser Automation Off-Main ✅ Passed The commit only changes CmuxTerminal teardown/lifetime code and tests; it doesn't touch browser.* socket commands, processV2Command, or socket-worker routing.
Cmux Expensive Synchronous Load ✅ Passed Diff only changes teardown ordering; no RestorableAgentSessionIndex.load(), JSON/transcript scans, or other expensive sync agent loads were added.
Cmux Cache Substitution Correctness ✅ Passed The diff only changes teardown ownership/order; it doesn’t replace any fresh read with a cached value in a persistence/history/snapshot path.
Cmux No Hacky Sleeps ✅ Passed PR only changes Swift sources/tests; the runtime-no-hacky-sleeps rule applies to non-Swift runtime scripts, and no such files were modified.
Cmux Algorithmic Complexity ✅ Passed The diff only threads extra teardown userdata and adds test scaffolding; no new scalable scans, sorts, or per-target rescans appear in production code.
Cmux Swift Concurrency ✅ Passed No new legacy async pattern is introduced; the fire-and-forget Task was an existing deinit→actor hop, and this diff only transports more teardown data through it.
Cmux Swift @Concurrent ✅ Passed PASS: the new async free runs from Task.detached on a utility worker, and the UI-facing teardown methods remain @MainActor; no invalid or missing @concurrent use.
Cmux Swift File And Package Boundaries ✅ Passed Focused teardown-lifetime bug fix in CmuxTerminal glue/package code; new files are small, oversized files got incidental small edits, and tests are isolated.
Cmux Swiftpm Lockfiles ✅ Passed PR diff only touches Swift sources/tests; no cmux .gitignore, Package.resolved, or cmux.xcodeproj package-reference changes were present.
Cmux Swift Logging ✅ Passed No prohibited logging APIs were added in the changed Swift files; only existing DEBUG-gated logDebugEvent calls appear, which the rule allows.
Cmux User-Facing Error Privacy ✅ Passed The diff only changes teardown plumbing, debug comments/logs, and tests; it adds no user-facing errors, alerts, or recovery copy exposing sensitive details.
Cmux Full Internationalization ✅ Passed Only teardown logic, comments, and tests changed; no user-facing text, string catalogs, or plist locale entries were added or modified.
Cmux Swiftui State Layout ✅ Passed PR only changes teardown/lifetime code and tests; no new GeometryReader, lazy-row store refs, or render-time state writes. Touched ObservableObject/@published is legacy.
Cmux Architecture Rethink ✅ Passed Production code keeps one teardown owner/coordinator and releases callback userdata only after native free; the only lock is in test-only synchronization.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Diff only changes teardown/lifetime code and a test; no new NSWindow/WindowGroup, window-controller, or cmuxAuxiliaryWindowIdentifiers changes appear.
Cmux Source Artifacts ✅ Passed All changed paths are intentional Swift source/test files; no logs, caches, tmp dirs, or generated artifacts were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The production diffs only change teardown ownership/order; added lines contain no new ForTesting/debug… seam markers, and existing debug hooks were pre-existing.
Cmux No Ambient Global State ✅ Passed No new ambient globals/singletons were added; new teardown state lives on TerminalSurface, an actor, and small test-owned helper types.
✨ 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.

@ejc3
ejc3 marked this pull request as ready for review July 14, 2026 08:15
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR keeps terminal callback userdata alive until native surface teardown completes. The main changes are:

  • Transport MANUAL-I/O and byte-tee userdata through deferred teardown requests.
  • Release all callback userdata after the native surface free returns.
  • Add deterministic teardown-order tests for explicit teardown, hibernation, deinitialization, and coordinator execution.

Confidence Score: 5/5

This looks safe to merge after tightening the deinit ordering test.

  • No blocking production issue was found in the changed teardown flow.
  • The deinit test does not directly prove native-free-before-release ordering.

Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift

Important Files Changed

Filename Overview
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift Transports and releases all callback userdata after the native free completes.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift Extends the teardown ownership envelope with MANUAL-I/O and byte-tee userdata.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift Transfers callback userdata through deferred DEBUG teardown paths instead of releasing it early.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift Transfers all retained callback userdata to the coordinator during deinitialization.
Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift Adds teardown lifetime tests, but the deinit test does not observe the native-free event.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant S as TerminalSurface
    participant C as TeardownCoordinator
    participant G as Ghostty Surface
    participant IO as I/O Threads
    participant M as MainActor

    S->>C: Enqueue surface and callback userdata
    C->>G: ghostty_surface_free(surface)
    G->>IO: Stop and join threads
    IO-->>G: Callbacks complete
    G-->>C: Free returns
    C->>M: Release callback contexts and tee lease
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant S as TerminalSurface
    participant C as TeardownCoordinator
    participant G as Ghostty Surface
    participant IO as I/O Threads
    participant M as MainActor

    S->>C: Enqueue surface and callback userdata
    C->>G: ghostty_surface_free(surface)
    G->>IO: Stop and join threads
    IO-->>G: Callbacks complete
    G-->>C: Free returns
    C->>M: Release callback contexts and tee lease
Loading

Reviews (1): Last reviewed commit: "terminal: release tee and manual-IO call..." | Re-trigger Greptile

Comment on lines +76 to +77
await recorder.waitForEventCount(1)
#expect(recorder.events == [.teeLeaseRelease])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Deinit Ordering Is Not Observed

This test records only the tee release because deinit does not use runtimeSurfaceFreeOverrideForTesting. It would still pass if the coordinator released the lease before the native free, so the deinit path’s claimed ordering regression is not covered.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — as written this test never observed the native free, so it could not catch the wrong ordering. The version of this change that merged to main via #7996 fixes exactly that: it installs the free override and asserts [.nativeFree, .teeLeaseRelease]. Closing this PR since main now carries the full change.

@ejc3

ejc3 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Superseded: this change merged to main inside #7996 (3d508bc, 2026-07-15) — TerminalSurfaceRuntimeTeardownRequest and the coordinator's free-then-release ordering are byte-identical on main, and main's deinit test is stronger than the one here (it records the native free and asserts the two-event order). Nothing in this branch is missing from main.

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