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
41 changes: 40 additions & 1 deletion Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,16 @@ enum SessionFreshSpawnScrollbackSeed {
static func preparedText(for scrollback: String?) -> String? {
guard let scrollback else { return nil }
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
// The WAL byte stream can begin mid-escape-sequence: log rotation and
// ring overruns cut at byte boundaries, not sequence boundaries. When
// the surviving tail lost its ESC[ prefix, the parameter remainder
// ("38;5;114m") is plain text to every sanitizer below and renders
// literally at the head of the replay (2026-08-20 update-reset report).
// `truncatedScrollback`'s ANSI-safe start only guards its OWN cut, and
// only runs at all when the text exceeds the length cap — so the head
// must be repaired before anything else.
let headRepaired = strippedOrphanedSequenceHead(scrollback)
guard let truncated = SessionPersistencePolicy.truncatedScrollback(headRepaired) else { return nil }
let sanitized = positioningSanitizedText(truncated)
// Captured on the PRE-sanitization text, not the sanitized result:
// `positioningSanitizedText` strips every DEC private mode sequence
Expand All @@ -711,6 +720,36 @@ enum SessionFreshSpawnScrollbackSeed {
return ansiSafeReplayText(sanitized, forceModeReset: truncated.contains(ansiEscape))
}

/// Drops an orphaned CSI parameter tail from the very start of replay
/// text: CSI parameter/intermediate bytes (0x30-0x3F) followed by a final
/// byte (0x40-0x7E), with no preceding ESC. To keep false positives out of
/// legitimate prose ("1m 30s", "42x42 grid"), the fragment must contain at
/// least one `;` or `?` — real-world orphans are multi-parameter SGR/mode
/// sequences. A surviving single-parameter orphan renders as a couple of
/// literal characters, which is tolerable; a stripped legitimate line is
/// not. Bounded scan: parameter fragments are short.
static func strippedOrphanedSequenceHead(_ text: String) -> String {
var index = text.startIndex
var sawSeparator = false
var sawParameterByte = false
var steps = 0
while index < text.endIndex, steps < 64 {
guard let scalar = text[index].unicodeScalars.first?.value else { break }
if (0x30...0x3F).contains(scalar) {
sawParameterByte = true
if scalar == 0x3B || scalar == 0x3F { sawSeparator = true }
index = text.index(after: index)
steps += 1
continue
}
if (0x40...0x7E).contains(scalar), sawParameterByte, sawSeparator {
return String(text[text.index(after: index)...])
}
break
}
return text
}

/// Neutralizes width-dependent cursor-positioning escapes before replay.
///
/// `SessionWALStore.readFallbackScrollbackText` (used whenever a session's
Expand Down
49 changes: 49 additions & 0 deletions Sources/SessionWALStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,42 @@ final class SessionWALStore {
}
}

/// Records escrow facts for a revived session whose runtime surface has not
/// been created yet (a hidden tab at restore — its ghostty surface, and with
/// it the normal `register`/`markEscrowed` flow, only exists once the tab is
/// first shown). Creates the writer (and the session directory + meta.json)
/// if needed so the facts are durable NOW; the eventual full `register()`
/// replaces the writer but re-hydrates these fields from disk (see
/// `startWriter`), so nothing is lost at realization.
func stampDeferredReviveEscrow(
surfaceId: String,
socketPath: String,
token: String,
childPID: Int32?,
workingDirectory: String?
) {
writeQueue.async { [weak self] in
guard let self else { return }
if self.writersBySurfaceId[surfaceId] == nil {
self.startWriter(
surfaceId: surfaceId,
context: Context(surfaceId: surfaceId),
workingDirectory: workingDirectory
)
}
guard let writer = self.writersBySurfaceId[surfaceId] else { return }
writer.escrowed = true
writer.escrowSocketPath = socketPath
writer.escrowToken = token
if writer.childPID == nil, let childPID {
writer.childPID = childPID
}
let now = Date()
self.writeMeta(writer: writer, at: now)
writer.lastMetaWriteAt = now
}
}

/// Wires the main-thread/AppKit-bound VT screen export in
/// (`TerminalController.captureSessionWALFrameText(forSurfaceId:)`).
/// Safe to call once at app startup, before or after any surface
Expand Down Expand Up @@ -1095,6 +1131,19 @@ final class SessionWALStore {
return FileManager.default.homeDirectoryForCurrentUser.path
}()
let writer = SessionWALWriter(context: context, paths: paths, workingDirectory: resolvedWorkingDirectory)
// Durable-fact hydration: a re-registration for a surfaceId that already
// has a meta.json on disk (deferred-revive escrow stamp before the runtime
// surface exists, or a runtime-surface recreation) must not clobber facts
// recorded earlier — escrow state and child identity are written once and
// the NEXT launch's reattach depends on reading them back.
if let data = try? Data(contentsOf: paths.metaURL),
let existing = try? Self.metaDecoder.decode(SessionWALMeta.self, from: data) {
writer.escrowed = existing.escrowed ?? false
writer.escrowSocketPath = existing.escrowSocketPath
writer.escrowToken = existing.escrowToken
writer.childPID = existing.childPID
writer.ptyPath = existing.ptyPath
}
writersBySurfaceId[surfaceId] = writer
let now = Date()
writeMeta(writer: writer, at: now)
Expand Down
49 changes: 49 additions & 0 deletions Sources/TerminalSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,18 @@ final class TerminalSurface: Identifiable, ObservableObject {
// Surface is created when attached to a view
hostedView.attachSurface(self)
TerminalSurfaceRegistry.shared.register(self)

// Deferred-realization revival fix (2026-08-20): a revived panel restored
// into a hidden tab may never create its runtime surface this launch, so
// the normal re-escrow trigger (resolveSessionWALIdentity, run from
// createSurface) may never fire. Until it does, the retrieved master fd
// exists ONLY in this process — the next quit or crash closes it and
// SIGHUPs the child, which is the "every tab except the active one gets
// reset on update" report. Hand the fd to the escrow holder immediately
// instead of waiting for the tab to be shown.
if let descriptor = reviveDescriptor, !SessionMachineryGate.isUnitTesting {
escrowRevivedDescriptorImmediately(descriptor)
}
}


Expand Down Expand Up @@ -1828,6 +1840,43 @@ final class TerminalSurface: Identifiable, ObservableObject {
SessionEscrowClient.shared.release(surfaceId: id.uuidString, tokenHex: tokenHex)
}

/// Escrows a revive descriptor's master fd at panel construction, before any
/// runtime surface exists. Sets `hasAttemptedSessionEscrow` first so the
/// identity retry loop (`resolveSessionWALIdentity`, run at realization)
/// keeps its one-shot discipline and never double-escrows the same surface.
/// The escrow facts land via `SessionWALStore.stampDeferredReviveEscrow`,
/// which is safe to call before the WAL writer's full registration.
private func escrowRevivedDescriptorImmediately(_ descriptor: TerminalSurfaceReviveDescriptor) {
guard !hasAttemptedSessionEscrow else { return }
hasAttemptedSessionEscrow = true
guard let childPID = Int32(exactly: descriptor.childPID) else { return }
let dupedFD = dup(descriptor.masterFD)
guard dupedFD >= 0 else { return }
let surfaceId = id.uuidString
let walWorkingDirectory = workingDirectory
SessionEscrowClient.shared.escrow(
surfaceId: surfaceId,
dupedMasterFD: dupedFD,
childPID: childPID
) { [weak self] result in
guard let result else {
dilog("escrow.reattach", "early_reescrow session=\(surfaceId.prefix(8)) outcome=failed")
return
}
dilog("escrow.reattach", "early_reescrow session=\(surfaceId.prefix(8)) outcome=ok")
SessionWALStore.shared.stampDeferredReviveEscrow(
surfaceId: surfaceId,
socketPath: result.socketPath,
token: result.tokenHex,
childPID: childPID,
workingDirectory: walWorkingDirectory
)
// Kept in memory so a genuine close can authenticate the release
// frame — same contract as the realization-path escrow below.
DispatchQueue.main.async { self?.escrowTokenHex = result.tokenHex }
}
}

private func attemptSessionEscrow(surface: ghostty_surface_t, surfaceId: String, childPID: Int32) {
guard !SessionMachineryGate.isUnitTesting else { return }
hasAttemptedSessionEscrow = true
Expand Down
42 changes: 42 additions & 0 deletions programaTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2316,3 +2316,45 @@ final class ReviveReplayMainThreadRegressionTests: XCTestCase {
)
}
}

// 2026-08-20 update-reset report: WAL rotation and ring overruns cut the byte
// stream mid-escape-sequence, leaving an orphaned parameter tail ("38;114m")
// with no ESC byte at the head of the replay — plain text to every sanitizer,
// rendered literally. The head repair must strip it without eating legitimate
// prose that merely looks parameter-ish.
final class ScrollbackSeedOrphanedHeadTests: XCTestCase {
func testOrphanedSGRTailAtHeadIsStripped() {
let corrupt = "38;114mreturn }\nnext line"
XCTAssertEqual(
SessionFreshSpawnScrollbackSeed.strippedOrphanedSequenceHead(corrupt),
"return }\nnext line"
)
}

func testOrphanedPrivateModeTailAtHeadIsStripped() {
let corrupt = "?1003hprompt$ "
XCTAssertEqual(
SessionFreshSpawnScrollbackSeed.strippedOrphanedSequenceHead(corrupt),
"prompt$ "
)
}

func testLegitimateProseHeadsAreUntouched() {
for text in ["1m 30s elapsed\n", "42x42 grid\n", "2026-08-20 log line\n", "500 OK\n", "plain text"] {
XCTAssertEqual(
SessionFreshSpawnScrollbackSeed.strippedOrphanedSequenceHead(text),
text,
"must not strip: \(text)"
)
}
}

func testPreparedTextRepairsCorruptHeadEndToEnd() {
let prepared = SessionFreshSpawnScrollbackSeed.preparedText(for: "38;114mreturn }\nnext line\n")
XCTAssertNotNil(prepared)
XCTAssertFalse(
prepared?.contains("38;114m") ?? true,
"the orphaned fragment must not survive into the seeded replay"
)
}
}
36 changes: 36 additions & 0 deletions programaTests/SessionWALCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,39 @@ final class SessionWALCoreTests: XCTestCase {
}
}
}

// Deferred-revive escrow stamp (2026-08-20 update-reset fix): a revived panel in
// a hidden tab escrows its fd at construction, before the WAL writer's full
// registration. The stamp must create the session's meta.json on its own and
// record every field the next launch's reattach guard requires
// (escrowed/socketPath/token/childPID) — a missing one degrades to
// "not_escrowed" and the agent dies at the next update.
final class SessionWALDeferredReviveEscrowTests: XCTestCase {
func testStampWritesRetrievableEscrowMetaWithoutFullRegistration() {
let store = SessionWALStore.shared
let sessionId = UUID().uuidString
defer { store.unregister(surface: nil, surfaceId: sessionId, deleteDirectory: true) }

store.stampDeferredReviveEscrow(
surfaceId: sessionId,
socketPath: "/tmp/test-escrow.sock",
token: "deadbeefcafe",
childPID: 4242,
workingDirectory: "/tmp"
)

// Writes land asynchronously on the store's write queue; poll briefly.
let deadline = Date().addingTimeInterval(3)
var meta: SessionWALMeta?
while Date() < deadline {
meta = store.readMeta(sessionId: sessionId)
if meta?.escrowed == true { break }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}

XCTAssertEqual(meta?.escrowed, true, "stamp must persist escrowed=true without a prior register()")
XCTAssertEqual(meta?.escrowSocketPath, "/tmp/test-escrow.sock")
XCTAssertEqual(meta?.escrowToken, "deadbeefcafe")
XCTAssertEqual(meta?.childPID, 4242, "reattach's guard requires childPID; the stamp must record it")
}
}
Loading