Files
orca/src/main/terminal-scrollback-snapshots.ts
T
Neil 8ab85c9bfc fix(quit): stop durable state writes from parking the main thread on quit (#11931)
* fix(quit): stop durable state writes from parking the main thread

will-quit ran stats.flush() and store.flush() synchronously, before
preventDefault(). Both fsync and rename a multi-MB file on the profile
directory. When that directory sits on a stalled network mount the
syscall enters an uninterruptible wait: the app stops repainting and
stops responding to Force Quit, because a process blocked in the kernel
ignores SIGTERM and SIGKILL alike.

The existing 20s teardown deadline could not bound this. Its timer runs
on the very thread the syscall parked, so it never fires. The fix is to
make the quit path awaitable rather than to try to bound it — a quit
that is slow but responsive stays killable by the OS.

- preventDefault() now runs first, so every teardown step is free to await
- stats and state gain flushAsync() twins that use node:fs/promises
- both join the existing teardown barrier, which can now actually bound them
- the pass-2 will-quit re-entry returns early instead of re-running teardown
- quitFlushStarted makes the quit flush the last write, so a teardown step
  touching the store cannot arm a debounce that races process exit

Making the swap async cost the atomicity of check-generation-then-rename:
a writer parked on await rename has already cleared the guard, so a later
synchronous flush could be clobbered by stale state. Both async writers now
claim their temp path, and the sync writers delete it, turning that swap
into a swallowed ENOENT.

Atomic temp+rename is unchanged, so a write cut short by the deadline
leaves the previous file whole — bounded loss, never corruption.

* fix(quit): harden async persistence finalization

* fix(persistence): bound best-effort flushes
2026-08-01 19:22:39 -07:00

267 lines
7.9 KiB
TypeScript

import { createHash } from 'node:crypto'
import {
closeSync,
mkdirSync,
openSync,
readSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import type { WorkspaceSessionState } from '../shared/types'
import {
TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT,
TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT
} from '../shared/terminal-scrollback-limits'
const SNAPSHOT_DIR_NAME = 'terminal-scrollback'
const REF_PREFIX = 'v1'
export type TerminalScrollbackSnapshotStorage = {
snapshotRoot?: string
fallbackSnapshotRoot?: string | null
}
function getLegacySnapshotRoot(): string {
return join(app.getPath('userData'), SNAPSHOT_DIR_NAME)
}
export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): string {
return join(dirname(dataFile), SNAPSHOT_DIR_NAME)
}
function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string {
return storage?.snapshotRoot ?? getLegacySnapshotRoot()
}
export function makeTerminalScrollbackSnapshotRef(tabId: string, leafId: string): string {
const hash = createHash('sha256').update(`${tabId}\0${leafId}`).digest('hex').slice(0, 32)
return `${REF_PREFIX}-${hash}`
}
function snapshotPath(ref: string, snapshotRoot: string): string | null {
if (!/^v1-[0-9a-f]{32}$/.test(ref)) {
return null
}
return join(snapshotRoot, `${ref}.bin`)
}
function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] {
const primaryRoot = getSnapshotRoot(storage)
const primaryPath = snapshotPath(ref, primaryRoot)
if (!primaryPath) {
return []
}
const fallbackRoot = storage?.fallbackSnapshotRoot ?? null
if (!fallbackRoot || fallbackRoot === primaryRoot) {
return [primaryPath]
}
const fallbackPath = snapshotPath(ref, fallbackRoot)
return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath]
}
function trailingUtf8Bytes(value: string, maxBytes: number): Buffer {
const bytes = Buffer.from(value, 'utf-8')
if (bytes.length <= maxBytes) {
return bytes
}
let start = bytes.length - maxBytes
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) {
start++
}
return bytes.subarray(start)
}
function readTrailingUtf8(path: string, maxBytes: number): string {
const size = statSync(path).size
const length = Math.min(size, maxBytes)
if (length <= 0) {
return ''
}
const bytes = Buffer.allocUnsafe(length)
const fd = openSync(path, 'r')
try {
readSync(fd, bytes, 0, length, size - length)
} finally {
closeSync(fd)
}
let start = 0
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) {
start++
}
return bytes.subarray(start).toString('utf-8')
}
export function writeTerminalScrollbackSnapshotSync(args: {
tabId: string
leafId: string
buffer: string
storage?: TerminalScrollbackSnapshotStorage
}): string | null {
if (!args.buffer) {
return null
}
const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId)
const snapshotRoot = getSnapshotRoot(args.storage)
const path = snapshotPath(ref, snapshotRoot)
if (!path) {
return null
}
try {
mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 })
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT)
let renamed = false
try {
writeFileSync(tmpPath, bytes, { mode: 0o600 })
renameSync(tmpPath, path)
renamed = true
} finally {
if (!renamed) {
rmSync(tmpPath, { force: true })
}
}
return ref
} catch (err) {
console.warn(
`[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}`
)
return null
}
}
export async function writeTerminalScrollbackSnapshot(args: {
tabId: string
leafId: string
buffer: string
storage?: TerminalScrollbackSnapshotStorage
}): Promise<string | null> {
if (!args.buffer) {
return null
}
const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId)
const snapshotRoot = getSnapshotRoot(args.storage)
const path = snapshotPath(ref, snapshotRoot)
if (!path) {
return null
}
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
let renamed = false
try {
await mkdir(snapshotRoot, { recursive: true, mode: 0o700 })
const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT)
await writeFile(tmpPath, bytes, { mode: 0o600 })
await rename(tmpPath, path)
renamed = true
return ref
} catch (err) {
console.warn(
`[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}`
)
return null
} finally {
if (!renamed) {
await rm(tmpPath, { force: true }).catch(() => {})
}
}
}
export function readTerminalScrollbackSnapshotSync(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): string | null {
for (const path of snapshotReadPaths(ref, storage)) {
try {
return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)
} catch {
// Try the legacy/global fallback when a profile-local snapshot is absent.
}
}
return null
}
export function deleteTerminalScrollbackSnapshotSync(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): void {
for (const path of snapshotReadPaths(ref, storage)) {
try {
rmSync(path, { force: true })
} catch {
// Best-effort cleanup; stale refs are harmless and bounded by per-file caps.
}
}
}
export async function deleteTerminalScrollbackSnapshot(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): Promise<void> {
await Promise.all(
snapshotReadPaths(ref, storage).map((path) => rm(path, { force: true }).catch(() => {}))
)
}
export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionState): Set<string> {
const refs = new Set<string>()
for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) {
for (const ref of Object.values(layout.scrollbackRefsByLeafId ?? {})) {
refs.add(ref)
}
}
return refs
}
export function migrateWorkspaceSessionTerminalScrollbackSnapshots(
session: WorkspaceSessionState,
storage?: TerminalScrollbackSnapshotStorage
): { session: WorkspaceSessionState; changed: boolean } {
let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const buffers = layout.buffersByLeafId
if (!buffers || Object.keys(buffers).length === 0) {
continue
}
const refs = { ...layout.scrollbackRefsByLeafId }
const remainingBuffers: Record<string, string> = {}
let layoutChanged = false
for (const [leafId, buffer] of Object.entries(buffers)) {
const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer, storage })
if (ref) {
refs[leafId] = ref
layoutChanged = true
} else {
remainingBuffers[leafId] = buffer
if (refs[leafId]) {
delete refs[leafId]
layoutChanged = true
}
}
}
if (!layoutChanged) {
continue
}
terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId }
const nextLayout = { ...layout }
if (Object.keys(refs).length > 0) {
nextLayout.scrollbackRefsByLeafId = refs
} else {
delete nextLayout.scrollbackRefsByLeafId
}
if (Object.keys(remainingBuffers).length > 0) {
nextLayout.buffersByLeafId = remainingBuffers
} else {
delete nextLayout.buffersByLeafId
}
terminalLayoutsByTabId[tabId] = nextLayout
}
if (!terminalLayoutsByTabId) {
return { session, changed: false }
}
return { session: { ...session, terminalLayoutsByTabId }, changed: true }
}