From c948a7d5a8fa36281441bb51146dbe54a6352e05 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 14 Jul 2026 00:55:04 -0700 Subject: [PATCH 1/2] tests: pin tee-lease and manual-IO context release ordering to the native 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. --- .../RecordingTerminalByteTeeLease.swift | 15 ++ .../TeardownOrderRecorder.swift | 53 +++++++ ...SurfaceTeardownCallbackLifetimeTests.swift | 144 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingTerminalByteTeeLease.swift create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingTerminalByteTeeLease.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingTerminalByteTeeLease.swift new file mode 100644 index 000000000000..64384460bb88 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RecordingTerminalByteTeeLease.swift @@ -0,0 +1,15 @@ +@testable import CmuxTerminal + +/// A byte-tee lease that reports its release to a ``TeardownOrderRecorder`` +/// so lifetime tests can assert release ordering against the native free. +final class RecordingTerminalByteTeeLease: TerminalByteTeeLease { + private let recorder: TeardownOrderRecorder + + init(recorder: TeardownOrderRecorder) { + self.recorder = recorder + } + + func release() { + recorder.record(.teeLeaseRelease) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift new file mode 100644 index 000000000000..918f4e238fac --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TeardownOrderRecorder.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Records the order of teardown events emitted by synchronous callbacks (an +/// injected native free running on the teardown coordinator's worker, a byte-tee +/// lease release) and lets tests await a target event count without polling. +/// +/// @unchecked Sendable: all state is guarded by `lock`; the recording entry +/// points are synchronous callbacks with no async context (the sanctioned lock +/// carve-out for off-isolation compare-and-set). +final class TeardownOrderRecorder: @unchecked Sendable { + enum Event: Equatable, Sendable { + case nativeFree + case teeLeaseRelease + } + + private let lock = NSLock() + private var storedEvents: [Event] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + /// The events recorded so far, in order. + var events: [Event] { + lock.lock() + defer { lock.unlock() } + return storedEvents + } + + /// Records an event and resumes any waiter whose target count is reached. + func record(_ event: Event) { + lock.lock() + storedEvents.append(event) + let count = storedEvents.count + let resumable = waiters.filter { $0.count <= count }.map(\.continuation) + waiters.removeAll { $0.count <= count } + lock.unlock() + for continuation in resumable { + continuation.resume() + } + } + + /// Suspends until at least `count` events have been recorded. + func waitForEventCount(_ count: Int) async { + await withCheckedContinuation { continuation in + lock.lock() + if storedEvents.count >= count { + lock.unlock() + continuation.resume() + return + } + waiters.append((count, continuation)) + lock.unlock() + } + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift new file mode 100644 index 000000000000..b9bc432ec88d --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -0,0 +1,144 @@ +import AppKit +import Foundation +import GhosttyKit +import Testing +@testable import CmuxTerminal + +/// The ghostty PTY tee callback and the MANUAL-mode `io_write_cb` fire on +/// ghostty's IO threads until `ghostty_surface_free` joins those threads. The +/// retained callback userdata (the byte-tee lease's context and the manual IO +/// write box) must therefore stay alive until the native free has completed; +/// releasing earlier is a use-after-free window on the IO reader thread. +/// +/// These tests pin that ordering on every teardown path that defers the +/// native free to the runtime teardown coordinator. +@MainActor +@Suite(.serialized) struct TerminalSurfaceTeardownCallbackLifetimeTests { + @Test func teardownSurfaceKeepsTeeLeaseUntilNativeFree() async { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + surface.teardownSurface() + + // Still on the same main-actor turn: the lease release is only legal + // after the native free, which has not been awaited yet. + #expect( + !recorder.events.contains(.teeLeaseRelease), + "tee lease was released before the native free; the IO reader thread can still fire the tee callback" + ) + + await recorder.waitForEventCount(2) + #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) + } + + @Test func agentHibernationSuspendKeepsTeeLeaseUntilNativeFree() async { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + surface.suspendRuntimeSurfaceForAgentHibernation(reason: "test.hibernate") + + #expect( + !recorder.events.contains(.teeLeaseRelease), + "tee lease was released before the native free; the IO reader thread can still fire the tee callback" + ) + + await recorder.waitForEventCount(2) + #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) + } + + @Test func deinitKeepsTeeLeaseUntilCoordinatorFree() async { + let recorder = TeardownOrderRecorder() + var surface: TerminalSurface? = makeSurface() + surface?.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + surface?.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + + surface = nil + + // deinit enqueues the native free on the teardown coordinator; until + // that free runs, the tee lease must not have been released. + #expect( + !recorder.events.contains(.teeLeaseRelease), + "deinit released the tee lease inline instead of handing it to the teardown coordinator" + ) + + await recorder.waitForEventCount(1) + #expect(recorder.events == [.teeLeaseRelease]) + } + + @Test func teardownSurfaceKeepsManualIOContextUntilNativeFree() async { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + surface.installRuntimeSurfaceForTesting(fakeRuntimeSurface()) + surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + weak var weakBox: TerminalManualIOWriteBox? + // Immediately-executed closure so the only remaining strong reference + // is the retained Unmanaged context handed to the surface. + ({ + let box = TerminalManualIOWriteBox(onWrite: { _ in }) + weakBox = box + surface.manualIOContext = Unmanaged.passRetained(box) + })() + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + surface.teardownSurface() + + #expect( + weakBox != nil, + "manual IO write box was released before the native free; ghostty's IO thread can still invoke io_write_cb" + ) + + // The coordinator releases the manual IO context before the tee + // lease, so the lease event doubles as the completion beacon. + await recorder.waitForEventCount(2) + #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) + #expect(weakBox == nil, "manual IO write box must still be released after the native free") + } + + private func makeSurface() -> TerminalSurface { + let nativeView = FakeTerminalSurfaceNativeView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) + return TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + dependencies: TerminalSurfaceRuntimeDependencies( + registry: FakeSurfaceRegistry(), + engine: FakeTerminalEngine(), + viewProvider: FakeTerminalSurfaceViewProvider(surfaceView: nativeView, paneHost: paneHost), + spawnPolicy: FakeSpawnPolicyProvider(), + byteTee: FakeTerminalByteTee(), + rendererRealization: FakeRendererRealizationScheduler(), + hibernationRecorder: FakeHibernationRecorder(), + runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator(), + restoreSpawnScheduler: TerminalSurfaceRestoreSpawnScheduler(interSpawnDelay: .zero), + runtimeFilesystem: TerminalSurfaceRuntimeFilesystem( + claudeCommandShimTemporaryDirectory: URL(fileURLWithPath: "/tmp/cmux-terminal-tests", isDirectory: true), + installClaudeCommandShim: { _, _, _ in nil }, + isExecutableFile: { _ in false } + ), + sessionPortBase: 40_000, + sessionPortRangeSize: 100, + scrollbackReplayEnvironmentKey: "CMUX_TEST_SCROLLBACK_REPLAY" + ) + ) + } + + private func fakeRuntimeSurface() -> ghostty_surface_t { + UnsafeMutableRawPointer(bitPattern: 0x7541)! + } +} From 5f54f2be3aaf2d8bd054b4a73ea2e8afefcf2df9 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 14 Jul 2026 00:57:48 -0700 Subject: [PATCH 2/2] terminal: release tee and manual-IO callback userdata only after the native surface free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...nalSurfaceRuntimeTeardownCoordinator.swift | 61 ++++++++++++++++++- ...erminalSurfaceRuntimeTeardownRequest.swift | 17 +++++- .../TerminalSurface+RuntimeLifecycle.swift | 18 +++--- .../Surface/TerminalSurface.swift | 36 +++++++---- ...SurfaceTeardownCallbackLifetimeTests.swift | 23 +++++++ 5 files changed, 130 insertions(+), 25 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 379e0c8a15f2..2db734bd000e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -52,6 +52,55 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in ghostty_surface_free(surface) } + ) { + enqueueRuntimeTeardown( + id: id, + workspaceId: workspaceId, + reason: reason, + surface: surface, + callbackContext: callbackContext, + manualIOContext: nil, + byteTeeLease: nil, + freeSurface: freeSurface + ) + } + + /// Queues a native-surface free that also transports the surface's other + /// retained callback userdata. + /// + /// `ghostty_surface_free` is the synchronization point that joins + /// ghostty's IO threads: the io-reader thread fires the PTY tee callback + /// and the io thread fires the MANUAL-mode `io_write_cb` right up until + /// the free returns. Transporting the manual IO context and the byte-tee + /// lease through the request keeps their userdata retained across that + /// window; the coordinator releases them only after the free completes, + /// so no in-flight callback can dereference freed userdata. + /// + /// - Parameters: + /// - id: The owning surface id. + /// - workspaceId: The owning workspace id. + /// - reason: The teardown reason, for diagnostics. + /// - surface: The native surface pointer, already removed from all + /// main-thread owner state. + /// - callbackContext: The retained callback context released on the + /// main actor after the free completes. + /// - manualIOContext: The retained MANUAL-mode `io_write_cb` userdata, + /// released on the main actor after the free completes. + /// - byteTeeLease: The retained PTY tee lease, released on the main + /// actor after the free completes. + /// - freeSurface: The free operation; defaults to + /// `ghostty_surface_free`. + nonisolated func enqueueRuntimeTeardown( + id: UUID, + workspaceId: UUID, + reason: String, + surface: ghostty_surface_t, + callbackContext: Unmanaged?, + manualIOContext: Unmanaged?, + byteTeeLease: (any TerminalByteTeeLease)?, + freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in + ghostty_surface_free(surface) + } ) { let request = TerminalSurfaceRuntimeTeardownRequest( id: id, @@ -59,6 +108,8 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { reason: reason, surface: surface, callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: byteTeeLease, freeSurface: freeSurface ) Task { @@ -99,12 +150,18 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ) #endif request.freeSurface(request.surface) - if request.callbackContext != nil { + if request.callbackContext != nil || request.manualIOContext != nil || request.byteTeeLease != nil { // The request is the @unchecked Sendable transport for the - // Unmanaged context; release through the request so the @Sendable + // Unmanaged contexts; release through the request so the @Sendable // closure never captures the non-Sendable Unmanaged directly. + // Ordered after freeSurface: the native free joins ghostty's IO + // threads, so no tee/io_write callback can still hold this + // userdata. The byte-tee lease goes last so tests can use its + // release as the "all userdata released" beacon. await MainActor.run { request.callbackContext?.release() + request.manualIOContext?.release() + request.byteTeeLease?.release() } } #if DEBUG diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index 353e54f3a92d..3395648179c5 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -7,14 +7,23 @@ public import CmuxTerminalCore /// The native pointer has been removed from all main-thread owner state /// before this request is created; this wrapper only transports the one-shot /// free. It is `@unchecked Sendable` for exactly that reason: the surface -/// pointer and `Unmanaged` callback context are exclusively owned by the -/// request from creation until the coordinator consumes them. +/// pointer, the `Unmanaged` callback contexts, and the byte-tee lease are +/// exclusively owned by the request from creation until the coordinator +/// consumes them. +/// +/// The transported callback userdata (`callbackContext`, `manualIOContext`, +/// `byteTeeLease`) is released only after `freeSurface` returns: the native +/// free joins ghostty's IO threads (the io-reader thread that fires the PTY +/// tee callback and the io thread that fires the MANUAL-mode `io_write_cb`), +/// so a release ordered after the free can never race an in-flight callback. struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let id: UUID let workspaceId: UUID let reason: String let surface: ghostty_surface_t let callbackContext: Unmanaged? + let manualIOContext: Unmanaged? + let byteTeeLease: (any TerminalByteTeeLease)? let freeSurface: @Sendable (ghostty_surface_t) -> Void #if DEBUG let surfaceToken: String @@ -27,6 +36,8 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { reason: String, surface: ghostty_surface_t, callbackContext: Unmanaged?, + manualIOContext: Unmanaged?, + byteTeeLease: (any TerminalByteTeeLease)?, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void ) { self.id = id @@ -34,6 +45,8 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { self.reason = reason self.surface = surface self.callbackContext = callbackContext + self.manualIOContext = manualIOContext + self.byteTeeLease = byteTeeLease self.freeSurface = freeSurface #if DEBUG self.surfaceToken = String(id.uuidString.prefix(5)) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 9c4033464c1e..fdd18783cb61 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -263,18 +263,19 @@ extension TerminalSurface { #if DEBUG if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { + // Transport manualIOContext and teeLease through the request too: + // the coordinator releases all callback userdata only after the + // native free, which is what joins ghostty's IO threads. runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: "teardown", surface: surfaceToFree, callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease, freeSurface: freeSurface ) - // The teardown coordinator releases callbackContext; manualIOContext - // and teeLease are not transported through the request, so release them here. - manualIOContext?.release() - teeLease?.release() return } #endif @@ -332,18 +333,19 @@ extension TerminalSurface { #if DEBUG if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { + // Transport manualIOContext and teeLease through the request too: + // the coordinator releases all callback userdata only after the + // native free, which is what joins ghostty's IO threads. runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: reason, surface: surfaceToFree, callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease, freeSurface: freeSurface ) - // The teardown coordinator releases callbackContext; manualIOContext - // and teeLease are not transported through the request, so release them here. - manualIOContext?.release() - teeLease?.release() return } #endif diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 675b341acce8..98734faf1517 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -179,8 +179,11 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// For MANUAL-I/O remote tmux display surfaces: whether to suppress /// ghostty primary-screen reflow on resize. var manualIONoReflow = true - /// Retained userdata for the MANUAL-mode `io_write_cb`; released alongside - /// the surface. + /// Retained userdata for the MANUAL-mode `io_write_cb`. ghostty's io + /// thread can invoke the callback until `ghostty_surface_free` returns, so + /// every teardown path releases this strictly after the native free + /// (inline in the free task, or transported through the teardown + /// coordinator's request). var manualIOContext: Unmanaged? /// Output delivered before the runtime surface exists. Flushed once the /// surface is created so background mirror output is not lost. @@ -243,10 +246,16 @@ public final class TerminalSurface: Identifiable, ObservableObject { var claudeCommandShimPendingCreationSource: RuntimeSurfaceCreationSource? /// The retained byte-tee lease for the libghostty PTY tee callback (cmux /// fork extension). Installed in `createSurface` after - /// `ghostty_surface_new` succeeds; released alongside - /// `surfaceCallbackContext` whenever we tear down or rebuild the - /// surface. The Mac sync server reads the tee'd bytes to broadcast - /// raw PTY output to paired iPhones (`MobileTerminalByteTee`). + /// `ghostty_surface_new` succeeds. The Mac sync server reads the tee'd + /// bytes to broadcast raw PTY output to paired iPhones + /// (`MobileTerminalByteTee`). + /// + /// Lifetime: the tee callback fires on ghostty's io-reader thread for + /// every output chunk until `ghostty_surface_free` joins that thread, so + /// every teardown path releases the lease strictly after the native free + /// (inline in the free task, or transported through the teardown + /// coordinator's request). Releasing earlier is a use-after-free on the + /// io-reader thread. var mobileByteTeeLease: (any TerminalByteTeeLease)? /// The desired focus state for the Ghostty C surface. May be set before the /// C surface exists (e.g. during layout restoration); `createSurface` @@ -562,19 +571,20 @@ public final class TerminalSurface: Identifiable, ObservableObject { #endif // Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain - // callback userdata until surface free completes so callbacks never dereference - // a deallocated view pointer. + // ALL callback userdata until the native free completes: ghostty's io-reader + // thread keeps firing the PTY tee callback (and the io thread the MANUAL-mode + // io_write_cb) until ghostty_surface_free joins those threads, so releasing + // manualIOContext or teeLease here would leave a use-after-free window until + // the coordinator's deferred free runs. runtimeTeardown.enqueueRuntimeTeardown( id: id, workspaceId: tabId, reason: "deinit", surface: surfaceToFree, - callbackContext: callbackContext + callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease ) - // The teardown coordinator releases callbackContext; manualIOContext and - // teeLease are not transported through the request, so release them here. - manualIOContext?.release() - teeLease?.release() } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift index b9bc432ec88d..663621276a1f 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -109,6 +109,29 @@ import Testing #expect(weakBox == nil, "manual IO write box must still be released after the native free") } + @Test func coordinatorReleasesTransportedTeeLeaseOnlyAfterFreeCompletes() async { + let recorder = TeardownOrderRecorder() + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { surface.deallocate() } + + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.transport", + surface: surface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: RecordingTerminalByteTeeLease(recorder: recorder), + freeSurface: { _ in + recorder.record(.nativeFree) + } + ) + + await recorder.waitForEventCount(2) + #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) + } + private func makeSurface() -> TerminalSurface { let nativeView = FakeTerminalSurfaceNativeView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView)