Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions Sources/SessionWALStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ import Bonsplit
/// text, not a parallel restore path.
///
/// ## Cleanup
/// - A surface that tears down for real (`TerminalSurface.teardownSurface()`
/// or `deinit`, whichever actually runs the free — the other is a no-op
/// guarded by `surface == nil`) deletes its session directory.
/// - A finalized user close deletes its session directory. Shutdown and
/// deallocation preserve the directory because it contains the token needed
/// to reconnect any process retained by the escrow holder.
/// - Once restore has consumed (or found empty) an old session's WAL as
/// fallback, `Workspace+Persistence.swift` calls
/// `SessionWALStore.shared.discardOrphanedSession(sessionId:)` to delete
Expand Down Expand Up @@ -1127,12 +1127,18 @@ final class SessionWALStore {
/// flushes any remaining buffered bytes, and forgets the writer.
/// `deleteDirectory` should be `true` only at a surface's genuine final
/// teardown (normal close) — see the file-level "Cleanup" doc comment.
func unregister(surface: ghostty_surface_t?, surfaceId: String, deleteDirectory: Bool = false) {
func unregister(
surface: ghostty_surface_t?,
surfaceId: String,
deleteDirectory: Bool = false,
completion: (@Sendable () -> Void)? = nil
) {
if let surface {
ghostty_surface_set_pty_tee_cb(surface, nil, nil)
}
writeQueue.async { [weak self] in
self?.stopWriter(surfaceId: surfaceId, deleteDirectory: deleteDirectory)
completion?()
}
}

Expand Down
40 changes: 29 additions & 11 deletions Sources/TerminalSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
/// synchronously, so this stays a plain nonisolated method callable from
/// both `deinit` and the `@MainActor`-isolated `teardownSurface()`.
private func performSurfaceTeardown(reason: String) {
let isApplicationTerminating = SessionMachineryGate.isApplicationTerminating
// Stop any in-flight revive-replay chunk loop for this surface
// promptly, before the free below can enqueue behind it. Harmless
// (and cheap) to set even when no replay is running.
Expand All @@ -1028,7 +1029,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
TerminalController.unregisterRevivedRoot(authorizedRoot)
authorizedRevivedRootPID = nil
}
releaseEscrowedSessionIfClosedForGood(reason: reason)
if Self.shouldReleaseEscrowOnTeardown(reason: reason, isApplicationTerminating: isApplicationTerminating) {
SessionEscrowClient.shared.release(surfaceId: id.uuidString)
}
markPortalLifecycleClosed(reason: reason)

let callbackContext = surfaceCallbackContext
Expand Down Expand Up @@ -1088,10 +1091,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
// Keep free behavior aligned across teardown sites: perform the runtime
// teardown on the next main-actor turn so SIGHUP delivery is
// deterministic but non-reentrant. Clear the PTY tee right before
// free, per the C API contract. Both call sites are the surface's
// genuine normal-close path, so delete its WAL directory now that
// it's torn down.
SessionWALStore.shared.unregister(surface: surfaceToFree, surfaceId: surfaceIdForTap, deleteDirectory: true)
// free, per the C API contract. Retain recovery metadata whenever
// the holder retains the session, using the disposition captured
// before this asynchronous teardown was scheduled.
Self.unregisterSessionWALForTeardown(
surface: surfaceToFree,
surfaceId: surfaceIdForTap,
reason: reason,
isApplicationTerminating: isApplicationTerminating
)
GhosttyApp.cancelConfirmationsBeforeFree(surfaceToFree)
ghostty_surface_free(surfaceToFree)
GhosttySurfaceUserdataRegistry.release(callbackContext)
Expand Down Expand Up @@ -1935,12 +1943,22 @@ final class TerminalSurface: Identifiable, ObservableObject {
reason == "teardown" && !isApplicationTerminating
}

private func releaseEscrowedSessionIfClosedForGood(reason: String) {
guard Self.shouldReleaseEscrowOnTeardown(
reason: reason,
isApplicationTerminating: SessionMachineryGate.isApplicationTerminating
) else { return }
SessionEscrowClient.shared.release(surfaceId: id.uuidString)
nonisolated static func unregisterSessionWALForTeardown(
surface: ghostty_surface_t?,
surfaceId: String,
reason: String,
isApplicationTerminating: Bool,
completion: (@Sendable () -> Void)? = nil
) {
SessionWALStore.shared.unregister(
surface: surface,
surfaceId: surfaceId,
deleteDirectory: shouldReleaseEscrowOnTeardown(
reason: reason,
isApplicationTerminating: isApplicationTerminating
),
completion: completion
)
}

/// Escrows a revive descriptor's master fd at panel construction, before any
Expand Down
83 changes: 83 additions & 0 deletions programaTests/SessionWALCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,89 @@ final class SessionWALDeferredReviveEscrowTests: XCTestCase {
}
}

final class SessionWALTeardownRetentionTests: XCTestCase {
func testShutdownAndDeinitPreserveEscrowMetadataForRelaunch() throws {
let shutdownSession = try makeEscrowedSession()
let deinitSession = try makeEscrowedSession()
defer {
try? FileManager.default.removeItem(at: shutdownSession.paths.sessionDirectory)
try? FileManager.default.removeItem(at: deinitSession.paths.sessionDirectory)
}

let shutdownCompleted = expectation(description: "shutdown teardown completed")
TerminalSurface.unregisterSessionWALForTeardown(
surface: nil,
surfaceId: shutdownSession.id,
reason: "teardown",
isApplicationTerminating: true,
completion: { shutdownCompleted.fulfill() }
)
let deinitCompleted = expectation(description: "deinit teardown completed")
TerminalSurface.unregisterSessionWALForTeardown(
surface: nil,
surfaceId: deinitSession.id,
reason: "deinit",
isApplicationTerminating: false,
completion: { deinitCompleted.fulfill() }
)
wait(for: [shutdownCompleted, deinitCompleted], timeout: 5.0)

XCTAssertEqual(
SessionWALStore.shared.readMeta(sessionId: shutdownSession.id)?.escrowToken,
shutdownSession.token,
"an update restart must retain the claim token needed to reconnect the running terminal"
)
XCTAssertEqual(
SessionWALStore.shared.readMeta(sessionId: deinitSession.id)?.escrowToken,
deinitSession.token,
"deallocation without a finalized close must leave the terminal recoverable"
)
}

func testFinalizedUserCloseRemovesEscrowMetadata() throws {
let session = try makeEscrowedSession()
defer { try? FileManager.default.removeItem(at: session.paths.sessionDirectory) }

let teardownCompleted = expectation(description: "final close teardown completed")
TerminalSurface.unregisterSessionWALForTeardown(
surface: nil,
surfaceId: session.id,
reason: "teardown",
isApplicationTerminating: false,
completion: { teardownCompleted.fulfill() }
)
wait(for: [teardownCompleted], timeout: 5.0)

XCTAssertFalse(
FileManager.default.fileExists(atPath: session.paths.sessionDirectory.path),
"a finalized user close must remove recovery state so the closed terminal does not return on relaunch"
)
}

private func makeEscrowedSession() throws -> (id: String, token: String, paths: SessionWALPaths) {
let id = UUID().uuidString
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
let paths = try XCTUnwrap(SessionWALPaths.make(sessionId: id))
SessionWALStore.shared.stampDeferredReviveEscrow(
surfaceId: id,
socketPath: "/tmp/programa-test-escrow.sock",
token: token,
childPID: 4242,
workingDirectory: "/tmp"
)

let deadline = Date().addingTimeInterval(3)
while Date() < deadline {
if SessionWALStore.shared.readMeta(sessionId: id)?.escrowToken == token {
return (id, token, paths)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTFail("escrow fixture metadata was not persisted before teardown")
return (id, token, paths)
}
}

// Issue #307 orphan-reconciliation fix: `escrowedSessionIds(excluding:)` is the
// enumeration primitive `AppDelegate.reconcileOrphanedEscrowedSessions` uses to
// find escrow-claimed session directories the coarse-snapshot restore never
Expand Down
Loading