Files
orca/src/main/daemon/daemon-pty-buffer-snapshots.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

204 lines
8.2 KiB
TypeScript

import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DaemonPtySessionControl } from './daemon-pty-session-control'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import type { GetSnapshotResult } from './types'
import type { PtyProviderBufferSnapshot } from '../providers/types'
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
async getBufferSnapshot(
id: string,
opts: { scrollbackRows?: number } = {}
): Promise<PtyProviderBufferSnapshot | null> {
if (!this.supportsAuthoritativeBufferSnapshots) {
return null
}
try {
const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows)
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
sessionId: id,
...(scrollbackRows !== undefined ? { scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
return null
}
const restored =
this.historyManager &&
this.historyReader &&
(scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS)
? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows)
: snapshot
return this.toProviderBufferSnapshot(restored)
} catch {
return null
}
}
protected toProviderBufferSnapshot(
snapshot: NonNullable<GetSnapshotResult['snapshot']>
): PtyProviderBufferSnapshot | null {
if (typeof snapshot.outputSequence !== 'number') {
return null
}
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
return {
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
frameRestoreAnsi: snapshot.frameRestoreAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
cols: snapshot.cols,
rows: snapshot.rows,
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: snapshot.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks,
alternateScreen: snapshot.modes.alternateScreen,
// Why known `0` is carried too: it proves the app negotiated nothing at
// this boundary, which is a different fact from a source that cannot say.
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {}),
...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {})
}
}
protected async overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyManager || !this.historyReader) {
return liveSnapshot
}
// Why turn the caller away instead of queueing: this session already has a
// compact in flight whose result a third one would only duplicate, and an
// unbounded queue is how a stalled history filesystem grows without limit.
if (this.checkpointQueue.isSaturated(sessionId)) {
return liveSnapshot
}
// Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts.
// Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work.
if (!this.tryAdmitNonFinalCheckpoint(sessionId)) {
return liveSnapshot
}
// Why per session with a deadline: a reattach is a user click, so it must wait
// on this session's own compact and nothing else. A blown deadline does not
// cancel that compact — it keeps running and still commits — so the fallback
// costs restore depth for this one reattach, never durable history (STA-4173).
return await this.checkpointQueue.runWithDeadline(
sessionId,
async () => {
try {
return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows)
} finally {
this.releaseNonFinalCheckpointAdmission(sessionId)
this.overlayDeadlineWarnedSessionIds.delete(sessionId)
}
},
DURABLE_HISTORY_OVERLAY_DEADLINE_MS,
liveSnapshot,
{
onDeadline: () => {
if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) {
this.overlayDeadlineWarnedSessionIds.add(sessionId)
console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId)
}
}
}
)
}
protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean {
if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) {
if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) {
this.nonFinalAdmissionDeniedSessionIds.add(sessionId)
console.warn('[history] non-final checkpoint already in flight:', sessionId)
}
return false
}
if (
this.nonFinalCheckpointAdmissionSessionIds.size >=
(this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS
) {
this.reportNonFinalGlobalAdmissionDenial(sessionId)
return false
}
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.nonFinalCheckpointAdmissionSessionIds.add(sessionId)
return true
}
protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void {
if (!this.nonFinalGlobalAdmissionWarningActive) {
this.nonFinalGlobalAdmissionWarningActive = true
console.warn('[history] non-final checkpoint global admission limit reached:', sessionId)
}
}
protected releaseNonFinalCheckpointAdmission(sessionId: string): void {
if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) {
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.nonFinalGlobalAdmissionWarningActive = false
}
}
protected async compactDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
// Why compact before reading: an independent take/append races the 5s tick, can
// seq-gap the log, and would remount a stale checkpoint over the live window.
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint.checkpoint === 'committed') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
}
if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) {
return checkpoint.snapshot ?? liveSnapshot
}
if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) {
return checkpoint.snapshot
}
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (!restoreInfo) {
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot: checkpoint.snapshot,
restoreInfo,
scrollbackRows
})
} catch (error) {
console.warn('[history] durable snapshot overlay failed:', sessionId, error)
return liveSnapshot
}
}
async clearBuffer(id: string): Promise<void> {
await this.client.request('clearScrollback', { sessionId: id })
this.markSessionDirty(id)
}
acknowledgeDataEvent(_id: string, _charCount: number): void {
// No flow control for daemon-backed terminals
}
}