From 4749dfe787182478ff68ed921e2d3d0f9a86ae69 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 08:41:47 -0300 Subject: [PATCH 1/2] fix: re-escrow revived sessions immediately so background tabs survive the next update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session revived at restore only re-escrowed when its runtime surface was created, and surface creation is deferred until the tab's view is in a window (TerminalSurface.attachSurface: view.window == nil -> defer). A revived panel in a tab never shown during that app run held its child's only pty fd in app memory; the next update closed it and SIGHUPed the agent. Production diagnostics show it exactly: the 2026-08-19 21:05 relaunch revived 8/8 sessions, the 2026-08-20 11:26 relaunch revived 1/8 — the other 7 fell back not_escrowed, and their session ids are precisely the panel ids minted by the previous night's revival. One update of protection, then death. Three changes: - TerminalSurface escrows the revive descriptor's fd at construction (dup + hand to the holder), not at realization. hasAttemptedSessionEscrow keeps the realization-path escrow one-shot. - SessionWALStore.stampDeferredReviveEscrow records the escrow facts (escrowed/socketPath/token/childPID) before the WAL writer's full registration exists, creating the session dir + meta.json if needed — reattach's guard requires all four fields. - startWriter hydrates durable meta facts from an existing meta.json instead of clobbering them, so the eventual full registration (tab shown later, or runtime-surface recreation) preserves what the stamp wrote. This also fixes a latent clobber: any surface recreation previously wiped escrow state from meta.json. Observability: escrow.reattach early_reescrow outcome=ok/failed in the release diagnostics log. --- Sources/SessionWALStore.swift | 49 +++++++++++++++++++++++++ Sources/TerminalSurface.swift | 49 +++++++++++++++++++++++++ programaTests/SessionWALCoreTests.swift | 36 ++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/Sources/SessionWALStore.swift b/Sources/SessionWALStore.swift index be568fe7..865f6122 100644 --- a/Sources/SessionWALStore.swift +++ b/Sources/SessionWALStore.swift @@ -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 @@ -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) diff --git a/Sources/TerminalSurface.swift b/Sources/TerminalSurface.swift index 30ae81b4..2ceeceb4 100644 --- a/Sources/TerminalSurface.swift +++ b/Sources/TerminalSurface.swift @@ -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) + } } @@ -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 diff --git a/programaTests/SessionWALCoreTests.swift b/programaTests/SessionWALCoreTests.swift index 16c794b5..47cdbe4a 100644 --- a/programaTests/SessionWALCoreTests.swift +++ b/programaTests/SessionWALCoreTests.swift @@ -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") + } +} From 5fcd39c6f91ba097da684a5064748031acc6c797 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 08:45:10 -0300 Subject: [PATCH 2/2] fix: repair orphaned escape-sequence heads in scrollback replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WAL byte stream can begin mid-escape-sequence — log rotation and ring overruns cut at byte boundaries. When the cut lands inside a CSI sequence the surviving tail ("38;114m") has no ESC byte, so positioningSanitizedText and the ANSI-safe truncation (which only guards its own length-cap cut, and only runs above the cap) both pass it through, and it renders literally at the head of every fallback-restored terminal — the torn rendering in today's update-reset report. preparedText now strips a bare parameter-tail head (requires a ; or ? in the fragment so prose like "1m 30s" and "42x42" survives) before the rest of the pipeline. --- Sources/SessionPersistence.swift | 41 +++++++++++++++++++- programaTests/SessionPersistenceTests.swift | 42 +++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4cb9499f..48f2a301 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -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 @@ -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 diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 3397e401..6bed0ea3 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -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" + ) + } +}