mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
* 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
267 lines
7.9 KiB
TypeScript
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 }
|
|
}
|