diff --git a/config/scripts/capture-live-input-lag.mjs b/config/scripts/capture-live-input-lag.mjs new file mode 100644 index 00000000000..d2282d961d0 --- /dev/null +++ b/config/scripts/capture-live-input-lag.mjs @@ -0,0 +1,184 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + startRendererTimingProbe, + stopRendererTimingProbe +} from './idle-cpu-renderer-timing-probe.mjs' + +// Called with an already-attached main Orca page; never launches, focuses, or reloads it. +export async function captureLiveInputLag(page, durationMs = 30_000) { + if (!Number.isFinite(durationMs) || durationMs < 1_000 || durationMs > 60_000) { + throw new Error('Capture duration must be 1–60 seconds') + } + const identity = await page.evaluate(async () => { + if (!window.api?.app?.getIdentity) { + throw new Error('Target is not the main Orca renderer') + } + if (window.__orcaLiveInputLag || window.__orcaIdleCpuTimingProbe) { + throw new Error('A renderer timing probe already exists; stop it before capturing') + } + return window.api.app.getIdentity() + }) + const directory = await mkdtemp(join(tmpdir(), 'orca-input-lag-')) + const cdp = await page.context().newCDPSession(page) + let timingStarted = false + let inputStarted = false + let profilingStarted = false + try { + await startRendererTimingProbe(page) + timingStarted = true + await page.evaluate(() => { + const events = [] + const frames = [] + const observers = [] + const maxEntries = 3_000 + let dropped = 0 + const retain = (list, value) => { + if (list.length < maxEntries) { + list.push(value) + } else { + dropped++ + } + } + const surface = (target) => { + if (!(target instanceof Element)) { + return 'other' + } + if (target.closest('.xterm')) { + return 'terminal' + } + if (target.closest('.monaco-editor')) { + return 'editor' + } + if (target.closest('[contenteditable="true"]')) { + return 'contenteditable' + } + return target.matches('input, textarea') ? 'text-input' : 'other' + } + const onInput = (event) => { + retain(events, { + kind: 'listener', + type: event.type, + surface: surface(event.target), + eventAt: event.timeStamp, + handlerAt: performance.now(), + trusted: event.isTrusted + }) + } + const types = ['keydown', 'beforeinput', 'input', 'compositionstart', 'compositionend'] + for (const type of types) { + document.addEventListener(type, onInput, true) + } + const supported = PerformanceObserver.supportedEntryTypes ?? [] + if (supported.includes('event')) { + const observer = new PerformanceObserver((list) => { + for (const event of list.getEntries()) { + if (!types.includes(event.name)) { + continue + } + retain(events, { + kind: 'event-timing', + type: event.name, + surface: surface(event.target), + eventAt: event.startTime, + processingStart: event.processingStart, + processingEnd: event.processingEnd, + duration: event.duration, + interactionId: event.interactionId + }) + } + }) + observer.observe({ type: 'event', durationThreshold: 16 }) + observers.push(observer) + } + let last = performance.now() + let frameId + const frame = (now) => { + if (now - last > 32) { + retain(frames, { at: now, gapMs: now - last }) + } + last = now + frameId = requestAnimationFrame(frame) + } + frameId = requestAnimationFrame(frame) + const startedAt = performance.now() + const startedAtIso = new Date().toISOString() + window.__orcaLiveInputLag = { + stop: () => { + cancelAnimationFrame(frameId) + for (const type of types) { + document.removeEventListener(type, onInput, true) + } + for (const observer of observers) { + observer.disconnect() + } + delete window.__orcaLiveInputLag + return { + startedAt, + startedAtIso, + endedAt: performance.now(), + visibility: document.visibilityState, + events, + frames, + dropped, + eventTimingSupported: supported.includes('event') + } + } + } + }) + inputStarted = true + await cdp.send('Profiler.enable') + const profileStartWindow = [await page.evaluate(() => performance.now())] + await cdp.send('Profiler.start') + profilingStarted = true + profileStartWindow.push(await page.evaluate(() => performance.now())) + await new Promise((resolve) => setTimeout(resolve, durationMs)) + const { profile } = await cdp.send('Profiler.stop') + profilingStarted = false + const input = await page.evaluate(() => window.__orcaLiveInputLag.stop()) + inputStarted = false + const timing = await stopRendererTimingProbe(page) + await page.evaluate(() => { + delete window.__orcaIdleCpuTimingProbe + }) + timingStarted = false + await writeFile(join(directory, 'renderer.cpuprofile'), JSON.stringify(profile), { + mode: 0o600 + }) + await writeFile( + join(directory, 'input-timing.json'), + JSON.stringify( + { + identity, + input, + timing, + profileStartWindow, + limitation: + 'Keyboard dispatch, handlers and frames only; not PTY echo latency. Event Timing omits short events and rounds durations.' + }, + null, + 2 + ), + { mode: 0o600 } + ) + return { directory, eventRecords: input.events.length, slowFrames: input.frames.length, timing } + } finally { + if (profilingStarted) { + await cdp.send('Profiler.stop').catch(() => {}) + } + if (inputStarted) { + await page.evaluate(() => window.__orcaLiveInputLag?.stop()).catch(() => {}) + } + if (timingStarted) { + await stopRendererTimingProbe(page).catch(() => {}) + await page + .evaluate(() => { + delete window.__orcaIdleCpuTimingProbe + }) + .catch(() => {}) + } + await cdp.send('Profiler.disable').catch(() => {}) + await cdp.detach().catch(() => {}) + } +} diff --git a/config/scripts/capture-running-orca-lag.mjs b/config/scripts/capture-running-orca-lag.mjs new file mode 100644 index 00000000000..718f1cd19a7 --- /dev/null +++ b/config/scripts/capture-running-orca-lag.mjs @@ -0,0 +1,72 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { captureLiveInputLag } from './capture-live-input-lag.mjs' +import { connectOrcaMainInspector } from './orca-main-inspector-connection.mjs' + +// Diagnostic-only adapter: Electron CDP on the verified existing renderer; no window actions. +const expectedPid = Number(process.argv[2]) +const rendererId = Number(process.argv[3]) +if (!expectedPid || !rendererId) { + throw new Error('Usage: node capture-running-orca-lag.mjs MAIN_PID WEB_CONTENTS_ID') +} +const connection = await connectOrcaMainInspector(expectedPid, rendererId) +const { send, evaluateMain, evaluateRenderer, contents } = connection +let attached = false +let echoStarted = false +let mainProfiling = false +try { + const identity = await evaluateMain( + `({pid:process.pid,type:${contents}.getType(),rendererPid:${contents}.getOSProcessId(),attached:${contents}.debugger.isAttached()})` + ) + if (identity.pid !== expectedPid || identity.type !== 'window' || identity.attached) { + throw new Error(`Unexpected or already-debugged target: ${JSON.stringify(identity)}`) + } + const before = await evaluateRenderer('window.__orcaTypingDiagnostic.report()') + if (before.sampling.running) { + throw new Error('An existing typing diagnostic is running') + } + await evaluateMain(`${contents}.debugger.attach('1.3')`) + attached = true + const page = { + evaluate: (fn, arg) => evaluateRenderer(`(${fn.toString()})(${JSON.stringify(arg) ?? ''})`), + context: () => ({ + newCDPSession: async () => ({ + send: connection.cdp, + detach: async () => {} + }) + }) + } + await evaluateRenderer('window.__orcaTypingDiagnostic.start()') + echoStarted = true + await send('Profiler.enable') + await send('Profiler.start') + mainProfiling = true + console.log( + JSON.stringify({ captureStarted: new Date().toISOString(), identity, census: before.census }) + ) + const result = await captureLiveInputLag(page, 30_000) + const { profile } = await send('Profiler.stop') + mainProfiling = false + await evaluateRenderer('window.__orcaTypingDiagnostic.stop()') + echoStarted = false + const echo = await evaluateRenderer('window.__orcaTypingDiagnostic.report()') + await writeFile(join(result.directory, 'main.cpuprofile'), JSON.stringify(profile), { + mode: 0o600 + }) + await writeFile(join(result.directory, 'terminal-echo.json'), JSON.stringify(echo, null, 2), { + mode: 0o600 + }) + console.log(JSON.stringify({ ...result, echo }, null, 2)) +} finally { + if (echoStarted) { + await evaluateRenderer('window.__orcaTypingDiagnostic.stop()').catch(() => {}) + } + if (mainProfiling) { + await send('Profiler.stop').catch(() => {}) + } + await send('Profiler.disable').catch(() => {}) + if (attached) { + await evaluateMain(`${contents}.debugger.detach()`).catch(() => {}) + } + connection.close() +} diff --git a/config/scripts/lag-probe-failures.test.mjs b/config/scripts/lag-probe-failures.test.mjs new file mode 100644 index 00000000000..7402bbf6f94 --- /dev/null +++ b/config/scripts/lag-probe-failures.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' +import { runInNewContext } from 'node:vm' +import { installRendererIpcProbe } from './main-blocking-probe.mjs' +import { connectOrcaMainInspector } from './orca-main-inspector-connection.mjs' + +test('IPC polling records a rejection and permits the next poll', async () => { + let poll + let calls = 0 + const window = { + api: { + app: { + getIdentity: async () => { + if (++calls === 1) { + throw new Error('IPC disconnected') + } + } + } + } + } + runInNewContext(`(${String(installRendererIpcProbe)})()`, { + window, + performance, + Date, + document: { addEventListener() {}, removeEventListener() {} }, + setInterval(callback) { + poll = callback + return 1 + }, + clearInterval() {} + }) + await poll() + await poll() + const { requests } = window.__orcaIpcTimingProbe.stop() + assert.equal(requests.length, 2) + assert.match(requests[0].failed, /IPC disconnected/) + assert.equal(requests[1].failed, undefined) +}) + +test('socket closure rejects outstanding and subsequent requests without timeout timers', async () => { + let socket + const timers = new Set() + class FakeSocket { + static OPEN = 1 + readyState = 1 + constructor() { + socket = this + queueMicrotask(() => this.onopen()) + } + send(payload) { + const { id, params } = JSON.parse(payload) + if (params.expression === 'process.pid') { + queueMicrotask(() => + this.onmessage({ data: JSON.stringify({ id, result: { result: { value: 42 } } }) }) + ) + } + } + close() { + this.readyState = 3 + this.onclose() + } + } + const connect = runInNewContext(`(${String(connectOrcaMainInspector)})`, { + fetch: async () => ({ json: async () => [{ webSocketDebuggerUrl: 'ws://fixture' }] }), + WebSocket: FakeSocket, + setTimeout(callback) { + timers.add(callback) + return callback + }, + clearTimeout(timer) { + timers.delete(timer) + } + }) + const connection = await connect(42) + const first = connection.send('Profiler.start') + const second = connection.send('Profiler.stop') + socket.close() + await assert.rejects(first, /Inspector socket closed/) + await assert.rejects(second, /Inspector socket closed/) + await assert.rejects(connection.send('Profiler.enable'), /not open/) + assert.equal(timers.size, 0) +}) diff --git a/config/scripts/main-blocking-probe.mjs b/config/scripts/main-blocking-probe.mjs new file mode 100644 index 00000000000..93a5372a765 --- /dev/null +++ b/config/scripts/main-blocking-probe.mjs @@ -0,0 +1,120 @@ +export function installMainBlockingProbe() { + if (globalThis.__orcaMainBlockingProbe) { + throw new Error('Main blocking probe already exists') + } + const events = [] + const cleanup = [] + const startedAt = Date.now() + function wrap(object, name, label, sizeOf) { + const original = object[name] + const wrapped = function (...args) { + const start = performance.now() + const epoch = Date.now() + let result + try { + result = Reflect.apply(original, this, args) + return result + } finally { + const durationMs = performance.now() - start + if (durationMs >= 8 && events.length < 2000) { + events.push({ + epoch, + durationMs, + label, + size: sizeOf?.(args, result) ?? null, + stack: new Error('Main blocking call').stack?.split('\n').slice(2, 10) + }) + } + } + } + object[name] = wrapped + cleanup.push(() => { + if (object[name] === wrapped) { + object[name] = original + } + }) + } + wrap(JSON, 'stringify', 'JSON.stringify', (_args, result) => result?.length) + wrap(globalThis, 'structuredClone', 'structuredClone') + wrap(Buffer, 'from', 'Buffer.from', (args) => args[0]?.length) + const hashPrototype = Object.getPrototypeOf( + process.getBuiltinModule('crypto').createHash('sha256') + ) + wrap(hashPrototype, 'update', 'hash.update', (args) => args[0]?.length) + const fs = process.getBuiltinModule('fs') + for (const name of ['existsSync', 'accessSync', 'writeFileSync', 'fsyncSync', 'renameSync']) { + wrap(fs, name, name) + } + const timerGaps = [] + let previous = performance.now() + const timer = setInterval(() => { + const now = performance.now() + const gap = now - previous - 25 + previous = now + if (gap > 20 && timerGaps.length < 2000) { + timerGaps.push({ epoch: Date.now(), gapMs: gap }) + } + }, 25) + timer.unref() + globalThis.__orcaMainBlockingProbe = { + stop() { + clearInterval(timer) + for (const restore of cleanup.toReversed()) { + restore() + } + delete globalThis.__orcaMainBlockingProbe + return { startedAt, endedAt: Date.now(), events, timerGaps } + } + } + return { startedAt } +} + +export function installRendererIpcProbe() { + if (window.__orcaIpcTimingProbe) { + throw new Error('Renderer IPC probe already exists') + } + const requests = [] + const keys = [] + let pending = false + let stopped = false + const timer = setInterval(async () => { + if (pending || stopped) { + return + } + pending = true + const start = performance.now() + const epoch = Date.now() + try { + await window.api.app.getIdentity() + if (requests.length < 2000) { + requests.push({ epoch, durationMs: performance.now() - start }) + } + } catch (error) { + if (requests.length < 2000) { + requests.push({ epoch, durationMs: performance.now() - start, failed: String(error) }) + } + } finally { + pending = false + } + }, 100) + const keydown = (event) => { + if (keys.length < 1000) { + keys.push({ + epoch: Date.now(), + queueMs: performance.now() - event.timeStamp, + terminal: !!event.target?.closest?.('.xterm'), + trusted: event.isTrusted + }) + } + } + document.addEventListener('keydown', keydown, true) + window.__orcaIpcTimingProbe = { + stop() { + stopped = true + clearInterval(timer) + document.removeEventListener('keydown', keydown, true) + delete window.__orcaIpcTimingProbe + return { requests, keys } + } + } +} diff --git a/config/scripts/orca-main-inspector-connection.mjs b/config/scripts/orca-main-inspector-connection.mjs new file mode 100644 index 00000000000..1d77e728f89 --- /dev/null +++ b/config/scripts/orca-main-inspector-connection.mjs @@ -0,0 +1,84 @@ +export async function connectOrcaMainInspector(expectedPid, rendererId = 1) { + const [target] = await (await fetch('http://127.0.0.1:9229/json/list')).json() + const socket = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + const pending = new Map() + let nextId = 0 + socket.onclose = () => { + for (const [id, callback] of pending) { + pending.delete(id) + clearTimeout(callback.timer) + callback.reject(new Error('Inspector socket closed')) + } + } + socket.onmessage = (event) => { + const message = JSON.parse(event.data) + const callback = pending.get(message.id) + if (!callback) { + return + } + pending.delete(message.id) + clearTimeout(callback.timer) + if (message.error) { + callback.reject(new Error(JSON.stringify(message.error))) + } else { + callback.resolve(message.result) + } + } + function send(method, params = {}) { + return new Promise((resolve, reject) => { + if (socket.readyState !== WebSocket.OPEN) { + reject(new Error('Inspector socket is not open')) + return + } + const id = ++nextId + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out: ${method}`)) + }, 15_000) + pending.set(id, { resolve, reject, timer }) + try { + socket.send(JSON.stringify({ id, method, params })) + } catch (error) { + pending.delete(id) + clearTimeout(timer) + reject(error) + } + }) + } + async function evaluateMain(expression) { + const result = await send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + }) + if (result.exceptionDetails) { + throw new Error(result.result.description ?? JSON.stringify(result.exceptionDetails)) + } + return result.result.value + } + try { + if ((await evaluateMain('process.pid')) !== expectedPid) { + throw new Error('Inspector belongs to a different main process') + } + } catch (error) { + socket.close() + throw error + } + const contents = `process.getBuiltinModule('module').createRequire(process.execPath)('electron').webContents.fromId(${rendererId})` + return { + send, + evaluateMain, + contents, + evaluateRenderer: (expression) => + evaluateMain(`${contents}.executeJavaScript(${JSON.stringify(expression)})`), + cdp: (method, params = {}) => + evaluateMain( + `${contents}.debugger.sendCommand(${JSON.stringify(method)},${JSON.stringify(params)})` + ), + close: () => socket.close() + } +} diff --git a/config/scripts/persistence-call-probe.mjs b/config/scripts/persistence-call-probe.mjs new file mode 100644 index 00000000000..d62df17cc0b --- /dev/null +++ b/config/scripts/persistence-call-probe.mjs @@ -0,0 +1,85 @@ +export function installPersistenceCallProbe() { + const store = globalThis.__orcaLiveStoreProbeTarget + if (!store || globalThis.__orcaPersistenceCallProbe) { + throw new Error('Missing verified live store, or probe already active') + } + const contextSymbol = Object.getOwnPropertySymbols(store).find( + (symbol) => symbol.description === 'PrimaryStateWriteOperations' + ) + const serialization = store[contextSymbol]?.serialization + if (!serialization?.buildStateToSave) { + throw new Error('Live serialization context was not found') + } + const events = [] + const cleanup = [] + function wrap(object, name, describe) { + const descriptor = Object.getOwnPropertyDescriptor(object, name) + const original = object[name] + const wrapped = function (...args) { + const details = describe?.(args) ?? {} + const start = performance.now() + const epoch = Date.now() + let result + try { + result = Reflect.apply(original, this, args) + return result + } finally { + const durationMs = performance.now() - start + if (events.length < 1000) { + events.push({ + name, + epoch, + durationMs, + ...details, + payloadBytes: name === 'buildStateToSave' ? result?.payload?.length : undefined, + stack: + durationMs > 20 + ? new Error('Persistence timing').stack?.split('\n').slice(2, 9) + : undefined + }) + } + } + } + Object.defineProperty(object, name, { value: wrapped, configurable: true, writable: true }) + cleanup.push(() => { + if (object[name] !== wrapped) { + return + } + if (descriptor) { + Object.defineProperty(object, name, descriptor) + } else { + delete object[name] + } + }) + } + wrap(serialization, 'buildStateToSave') + wrap(store, 'flushOrThrow') + wrap(store, 'persistPtyBinding', ([args, hostId]) => { + if (hostId && hostId !== 'local') { + return { local: false } + } + const session = store.getWorkspaceSession() + const key = `${args.tabId}:${args.leafId}` + const worktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + const tab = session.tabsByWorktree?.[worktreeId]?.find((t) => t.id === args.tabId) + return { + local: true, + tabAlreadyBound: tab?.ptyId === args.ptyId, + leafAlreadyBound: + session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] === args.ptyId, + incarnationAlreadyMatches: + session.terminalPtyIncarnationsByPaneKey?.[key] === args.incarnationId, + layoutExists: !!session.terminalLayoutsByTabId?.[args.tabId]?.root + } + }) + globalThis.__orcaPersistenceCallProbe = { + stop() { + for (const restore of cleanup.toReversed()) { + restore() + } + delete globalThis.__orcaPersistenceCallProbe + delete globalThis.__orcaLiveStoreProbeTarget + return { events } + } + } +} diff --git a/config/scripts/terminal-render-phase-probe.mjs b/config/scripts/terminal-render-phase-probe.mjs new file mode 100644 index 00000000000..a5032712d25 --- /dev/null +++ b/config/scripts/terminal-render-phase-probe.mjs @@ -0,0 +1,129 @@ +/* eslint-disable no-control-regex -- Terminal control-sequence metadata, never input text. */ +export function installTerminalRenderPhaseProbe() { + if (window.__orcaRenderPhaseProbe || !window.__orcaLiveRenderPanes) { + throw new Error('Missing verified pane references, or another probe is active') + } + const events = [] + const cleanup = [] + let dropped = 0 + const startedAt = performance.now() + function record(pane, kind, extra = {}) { + if (!pane.terminal.element?.contains(document.activeElement)) { + return + } + const core = pane.terminal._core + if (events.length >= 5000) { + dropped++ + return + } + events.push({ + at: performance.now(), + leafId: pane.leafId, + kind, + paused: core?._renderService?._isPaused, + sync: core?.coreService?.decPrivateModes?.synchronizedOutput, + ...extra + }) + } + function wrap(object, key, makeWrapper) { + const original = object?.[key] + if (typeof original !== 'function') { + return + } + const wrapped = makeWrapper(original) + object[key] = wrapped + cleanup.push(() => { + if (object[key] === wrapped) { + object[key] = original + } + }) + } + function subscribe(object, key, listener) { + const disposable = object?.[key]?.(listener) + if (disposable) { + cleanup.push(() => disposable.dispose()) + } + } + for (const pane of window.__orcaLiveRenderPanes) { + const terminal = pane.terminal + const service = terminal?._core?._renderService + if (!service) { + continue + } + subscribe(terminal, 'onData', (data) => record(pane, 'dispatch', { bytes: data.length })) + subscribe(terminal, 'onWriteParsed', () => record(pane, 'parsed')) + subscribe(terminal, 'onRender', () => record(pane, 'public-render')) + subscribe(service, 'onRender', () => record(pane, 'service-render')) + wrap( + terminal, + 'write', + (original) => + function (data, ...args) { + if (terminal.element?.contains(document.activeElement)) { + const text = typeof data === 'string' ? data : new TextDecoder().decode(data) + const controls = [...text.matchAll(/\x1b\[([0-?]*)([ -/]*)([@-~])/g)] + const withoutControls = text + .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[()][0-~]/g, '') + .replace(/[\x00-\x1f\x7f]/g, '') + record(pane, 'write', { + bytes: data.length, + printableChars: withoutControls.length, + syncStarts: controls.filter((c) => c[1] === '?2026' && c[3] === 'h').length, + syncEnds: controls.filter((c) => c[1] === '?2026' && c[3] === 'l').length, + csiFinals: controls.map((c) => c[3]).join('') + }) + } + return original.call(this, data, ...args) + } + ) + wrap( + service, + 'refreshRows', + (original) => + function (start, end, sync, redrawOnly) { + record(pane, 'refresh-request', { + start, + end, + synchronous: !!sync, + redrawOnly: !!redrawOnly + }) + return original.call(this, start, end, sync, redrawOnly) + } + ) + const renderer = service._renderer?.value ?? service._renderer + wrap( + renderer, + 'renderRows', + (original) => + function (...args) { + const before = performance.now() + const result = original.apply(this, args) + record(pane, 'render-rows', { duration: performance.now() - before }) + return result + } + ) + } + const keydown = (event) => { + const pane = window.__orcaLiveRenderPanes.find((p) => + p.terminal.element?.contains(event.target) + ) + if (pane) { + record(pane, 'keydown', { eventAt: event.timeStamp, trusted: event.isTrusted }) + } + } + document.addEventListener('keydown', keydown, true) + cleanup.push(() => document.removeEventListener('keydown', keydown, true)) + window.__orcaRenderPhaseProbe = { + stop() { + for (const dispose of cleanup.toReversed()) { + dispose() + } + delete window.__orcaRenderPhaseProbe + delete window.__orcaLiveRenderPanes + return { startedAt, endedAt: performance.now(), events, dropped } + } + } + return { startedAt, panes: window.__orcaLiveRenderPanes.length } +} diff --git a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts index b1ead181530..766decf1c9c 100644 --- a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts +++ b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts @@ -435,7 +435,8 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', tabId: 'tab-1', leafId, - ptyId: 'ssh-pty' + ptyId: 'ssh-pty', + origin: 'spawn' }, 'ssh:ssh-1' ) diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index f4f57fc6b0b..86fe3381f2b 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -159,7 +159,8 @@ describe('registerPtyHandlers', () => { leafId, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true + hostAdmittedMembership: true, + origin: 'spawn' }) }) it('shuts down a split PTY when its expected source binding was retired', async () => { @@ -518,7 +519,8 @@ describe('registerPtyHandlers', () => { leafId, ptyId: 'pty-shared', startupCwd: '/tmp', - hostAdmittedMembership: true + hostAdmittedMembership: true, + origin: 'spawn' }) }) }) diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index ee1bc16c75b..bdfd4118182 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -387,7 +387,8 @@ describe('registerPtyHandlers', () => { tabId: 'tab-race', leafId, ptyId: 'pty-renderer', - startupCwd: '/tmp' + startupCwd: '/tmp', + origin: 'spawn' }) }) it.each([ diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 06e8679ace4..b6aba0855e6 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -541,7 +541,8 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-1@@relay-pty', - hostAdmittedMembership: true + hostAdmittedMembership: true, + origin: 'spawn' }, 'ssh:ssh-1' ) diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index d1078477d12..b66cd30e71f 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -202,7 +202,8 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-reattach-ok@@relay-pty', - hostAdmittedMembership: true + hostAdmittedMembership: true, + origin: 'reattach' }, 'ssh:ssh-reattach-ok' ) diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index d9bee3e6934..7aaa90bf9b8 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -13,6 +13,7 @@ import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provide import { ptySizes } from '../delivery/visibility-state' import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size' import { clearProviderPtyState } from '../provider/state-cleanup' +import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ @@ -115,7 +116,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ leafId: ctx.validatedLeafId, ptyId: ctx.result.id, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}) + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + origin: spawnCommitBindingOrigin(ctx.result) } if (args.connectionId) { ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 731065e59ab..5d25e11f57c 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -14,6 +14,7 @@ import { import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' +import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' export type StablePaneOwner = { handle?: string @@ -200,7 +201,8 @@ export function persistAdmittedStablePaneBinding(args: { ptyId: args.result.id, ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - expectedBinding + expectedBinding, + origin: spawnCommitBindingOrigin(args.result) }, args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined ) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 7f8a9e38267..09d41460263 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -34,6 +34,7 @@ import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared import { clearProviderPtyState } from '../provider/state-cleanup' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority' +import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { RuntimePtySpawnState } from './spawn-state' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { @@ -159,7 +160,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), ...(ctx.hostSessionBinding.expectedSourceBinding ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } - : {}) + : {}), + origin: spawnCommitBindingOrigin(ctx.result, ctx.hostSessionBinding.expectedSourceBinding) } const persisted = args.connectionId ? ctx.hostSessionBinding.store.persistPtyBinding( diff --git a/src/main/persistence-flush-and-save-scheduling.test.ts b/src/main/persistence-flush-and-save-scheduling.test.ts index 74572c2a7d1..2d6180d5fa8 100644 --- a/src/main/persistence-flush-and-save-scheduling.test.ts +++ b/src/main/persistence-flush-and-save-scheduling.test.ts @@ -11,9 +11,14 @@ import { dataFile, writeDataFile, readDataFile, - makeRepo + makeRepo, + makeTerminalTab } from './persistence-test-harness' -import { TEST_LEAF_1 } from './persistence-session-fixtures' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { _resetTracerForTests, setActiveSink } from './observability/tracer' +import { _resetPtyBindingSpanSamplingForTests } from './persistence/loading-store/pty-binding-span' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -64,6 +69,8 @@ describe('Store', () => { }) afterEach(() => { + vi.restoreAllMocks() + _resetPtyBindingSpanSamplingForTests() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 10. flush writes synchronously ───────────────────────────────── @@ -379,4 +386,367 @@ describe('Store', () => { store.flush() expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() }) + + // ── persistPtyBinding fast lane ──────────────────────────────────── + + describe('persistPtyBinding fast lane', () => { + const WORKTREE = 'repo1::/worktree' + const binding = { worktreeId: WORKTREE, tabId: 'tab1', leafId: TEST_LEAF_1, ptyId: 'pty-1' } + const paneKey = `tab1:${TEST_LEAF_1}` + + const boundSession = ( + overrides: Partial = {} + ): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: WORKTREE, + activeTabId: 'tab1', + tabsByWorktree: { + [WORKTREE]: [makeTerminalTab({ id: 'tab1', worktreeId: WORKTREE, ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + ...overrides + }) + + const runtimeCounters = (store: ReturnType) => { + const runtime = store['runtime'] + return { + writeGeneration: runtime.writeGeneration, + lastDurableWriteGeneration: runtime.lastDurableWriteGeneration + } + } + + afterEach(() => { + _resetTracerForTests() + }) + + it.each([undefined, 'ssh:ssh-1', 'runtime:runtime-1'])( + 'skips the clone and the flush when the binding is already durable on %s', + async (hostId) => { + const store = await createStore() + store.setWorkspaceSession(boundSession(), hostId) + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + const inoBefore = statSync(dataFile()).ino + const flushSpy = vi.spyOn(store, 'flushOrThrow') + const cloneSpy = vi.spyOn(globalThis, 'structuredClone') + + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + + expect(flushSpy).not.toHaveBeenCalled() + expect(cloneSpy).not.toHaveBeenCalled() + expect(statSync(dataFile()).ino).toBe(inoBefore) + } + ) + + it('flushes while a save is pending, and the sync hash match makes the next call durable', async () => { + const store = await createStore() + store.setWorkspaceSession(boundSession()) + store.persistPtyBinding(binding) + const inoBefore = statSync(dataFile()).ino + // Bumps the write generation without changing any binding. + store.setWorkspaceSession({ ...store.getWorkspaceSession() }) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding(binding)).toBe(true) + expect(flushSpy).toHaveBeenCalledTimes(1) + expect(statSync(dataFile()).ino).toBe(inoBefore) + + // Without the writeToDiskSync counter fix the hash-match flush leaves the durable + // generation one behind and this third bind would flush again. + expect(store.persistPtyBinding(binding)).toBe(true) + expect(flushSpy).toHaveBeenCalledTimes(1) + }) + + it('falls through on an incarnation change and persists the new incarnation', async () => { + const store = await createStore() + store.setWorkspaceSession(boundSession()) + store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + expect(readDataFile()).toHaveProperty( + ['workspaceSession', 'terminalPtyIncarnationsByPaneKey', paneKey], + 'b' + ) + }) + + it('does not acknowledge an unpersisted binding published after the final flush', async () => { + const store = await createStore() + store.setWorkspaceSession(boundSession()) + store.persistPtyBinding(binding) + await store.flushAsync() + + const next = boundSession() + next.tabsByWorktree[WORKTREE][0].ptyId = 'pty-after-quit' + next.terminalLayoutsByTabId.tab1.ptyIdsByLeafId = { [TEST_LEAF_1]: 'pty-after-quit' } + store.setWorkspaceSession(next) + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE][0].ptyId).toBe('pty-after-quit') + + expect(() => store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' })).toThrow( + 'Cannot synchronously flush after final persistence has started' + ) + expect(readDataFile()).toHaveProperty( + ['workspaceSession', 'tabsByWorktree', WORKTREE, '0', 'ptyId'], + 'pty-1' + ) + }) + + it('treats an undefined incarnation against a recorded one as a miss', async () => { + const store = await createStore() + store.setWorkspaceSession(boundSession()) + store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding(binding)).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + }) + + it('falls through on a tombstone and lets the write path clear it', async () => { + const persisted = getDefaultPersistedState(testState.dir) + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] + persisted.workspaceSession = boundSession({ + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-1' }, + terminalSurfaceTombstonesByPaneKey: { + [paneKey]: { + worktreeId: WORKTREE, + parentTabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-1', + retiredAt: 1 + } + } + }) + writeDataFile(persisted) + const store = await createStore() + expect( + store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] + ).toBeDefined() + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + expect( + store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] + ).toBeUndefined() + }) + + it('still bumps the topology fence for a reconciled incarnation', async () => { + const store = await createStore() + store.setWorkspaceSession( + boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) + ) + store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) + const revisionBefore = + store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1 ?? 0 + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect( + store.persistPtyBinding({ + ...binding, + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1).toBe( + revisionBefore + 1 + ) + }) + + it('keeps every refusal ahead of the fast lane', async () => { + const store = await createStore() + store.setWorkspaceSession( + boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-1' } }) + ) + store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + const refusals = [ + { + ...binding, + expectedSourceBinding: { tabId: 'other-tab', leafId: TEST_LEAF_1, ptyId: 'pty-1' } + }, + { ...binding, incarnationId: 'inc-1', expectedBinding: { ptyId: 'pty-other' } }, + { ...binding, tabId: 'missing-tab', mayCreate: false } + ] + for (const refusal of refusals) { + expect(store.persistPtyBinding(refusal)).toBe(false) + } + expect(flushSpy).not.toHaveBeenCalled() + }) + + it.each([undefined, 'ssh:ssh-1', 'runtime:runtime-1'])( + 'flushes unrelated dirty state once, then skips unchanged reattachments on %s', + async (hostId) => { + const store = await createStore() + store.setWorkspaceSession(boundSession(), hostId) + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + expect(readDataFile()).toMatchObject({ + repos: expect.arrayContaining([expect.objectContaining({ id: 'r-dirty' })]) + }) + } + ) + + it('flushes again once the session object is replaced', async () => { + const store = await createStore() + store.setWorkspaceSession(boundSession()) + store.persistPtyBinding(binding) + // A renderer publish schedules another save, so global durability must be re-established. + store.setWorkspaceSession({ ...store.getWorkspaceSession() }) + store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding(binding)).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + }) + + it('flushes a changed pty for a pane whose old binding was durable', async () => { + const store = await createStore() + store.setWorkspaceSession(boundSession()) + store.persistPtyBinding(binding) + store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) + + expect(flushSpy).toHaveBeenCalledTimes(1) + expect(readDataFile()).toHaveProperty( + ['workspaceSession', 'terminalLayoutsByTabId', 'tab1', 'ptyIdsByLeafId', TEST_LEAF_1], + 'pty-next' + ) + }) + + it('lets every pane of a split tab hit the fast lane', async () => { + const store = await createStore() + store.setWorkspaceSession( + boundSession({ + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1', [TEST_LEAF_2]: 'pty-2' } + } + } + }) + ) + const sibling = { ...binding, leafId: TEST_LEAF_2, ptyId: 'pty-2' } + // First remount after a cold park: both panes reattach back to back. + expect(store.persistPtyBinding(binding)).toBe(true) + expect(store.persistPtyBinding(sibling)).toBe(true) + expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + // Second remount: neither pane may rewrite the tab row, so neither flushes. + expect(store.persistPtyBinding(sibling)).toBe(true) + expect(store.persistPtyBinding(binding)).toBe(true) + + expect(flushSpy).not.toHaveBeenCalled() + expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') + }) + + it('resolves the SSH partition without re-pointing it', async () => { + const store = await createStore() + const hostId = 'ssh:ssh-1' + store.setWorkspaceSession(boundSession(), hostId) + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + const partitionBefore = store.getWorkspaceSession(hostId) + const partitionsBefore = store['runtime'].state.workspaceSessionsByHostId + const flushSpy = vi.spyOn(store, 'flushOrThrow') + + expect(store.persistPtyBinding(binding, hostId)).toBe(true) + + expect(flushSpy).not.toHaveBeenCalled() + expect(store.getWorkspaceSession(hostId)).toBe(partitionBefore) + expect(store['runtime'].state.workspaceSessionsByHostId).toBe(partitionsBefore) + expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]).toBeUndefined() + }) + + it('records a sync hash match as durable', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + store.flushOrThrow() + const inoBefore = statSync(dataFile()).ino + const after = runtimeCounters(store) + expect(after.lastDurableWriteGeneration).toBe(after.writeGeneration) + + store.flushOrThrow() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + const counters = runtimeCounters(store) + expect(counters.writeGeneration).toBe(after.writeGeneration + 1) + expect(counters.lastDurableWriteGeneration).toBe(counters.writeGeneration) + }) + + it('emits one persistence.pty-binding span per call with its outcome', async () => { + const records: unknown[] = [] + setActiveSink({ + push: (record) => { + records.push(record) + }, + flush: () => {}, + close: () => {} + }) + const store = await createStore() + store.setWorkspaceSession(boundSession()) + + store.persistPtyBinding(binding) + store.persistPtyBinding(binding) + store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) + + const spans = records.filter( + (record) => + typeof record === 'object' && + record !== null && + 'name' in record && + record.name === 'persistence.pty-binding' + ) + expect(spans).toMatchObject([ + { + attributes: { + 'binding.outcome': 'flushed', + 'binding.eligible': false, + 'binding.misses': 'not_durable' + } + }, + { + attributes: { + 'binding.outcome': 'fast_lane', + 'binding.eligible': true, + 'binding.generation_gap': 0, + 'binding.host': 'local' + } + }, + { attributes: { 'binding.outcome': 'refused' } } + ]) + expect(JSON.stringify(spans)).not.toContain(TEST_LEAF_1) + expect(JSON.stringify(spans)).not.toContain('pty-1') + }) + }) }) diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index c112b4a9ba0..f61f6c691bd 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -231,6 +231,12 @@ export function writeToDiskSync( !opts.force && stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash ) { + // Why: flushOrThrow already bumped writeGeneration; the file holds this state, so record it + // durable or persistPtyBinding's fast lane stays parked one generation behind forever. + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + owner[primaryStateWriteOperationsContext].runtime.writeGeneration + ) return } const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts new file mode 100644 index 00000000000..8ac6de70a0d --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const WORKTREE = 'repo1::/worktree' +const request = { tabId: 'tab1', leafId: LEAF, ptyId: 'pty-1' } +const paneKey = `tab1:${LEAF}` + +function session(overrides: Partial = {}): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [ + { + id: 'tab1', + worktreeId: WORKTREE, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-1' } + } + }, + ...overrides + } +} + +describe('evaluatePtyBindingFastLane', () => { + it('is eligible only when memory matches and the session is durable', () => { + expect(evaluatePtyBindingFastLane(request, session(), WORKTREE, true)).toEqual({ + eligible: true, + misses: [] + }) + expect(evaluatePtyBindingFastLane(request, session(), WORKTREE, false)).toEqual({ + eligible: false, + misses: ['not_durable'] + }) + }) + + it('names every miss', () => { + const miss = ( + args: Partial[0]>, + state: WorkspaceSessionState = session() + ) => evaluatePtyBindingFastLane({ ...request, ...args }, state, WORKTREE, true).misses + + expect(miss({ expectedSourceBinding: {} })).toEqual(['split']) + expect(miss({ leafId: 'legacy-pane-1' })).toEqual(['legacy_leaf', 'leaf_absent', 'leaf_pty']) + expect(miss({}, session({ tabsByWorktree: {} }))).toEqual(['tab_missing']) + expect(miss({ ptyId: 'pty-2' })).toEqual(['tab_pty', 'leaf_pty']) + expect(miss({}, session({ terminalLayoutsByTabId: {} }))).toEqual(['layout_missing']) + expect( + miss( + {}, + session({ + terminalLayoutsByTabId: { + tab1: { root: null, activeLeafId: null, expandedLeafId: null, ptyIdsByLeafId: {} } + } + }) + ) + ).toEqual(['layout_missing']) + expect(miss({ incarnationId: 'a' })).toEqual(['incarnation']) + expect(miss({}, session({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'a' } }))).toEqual([ + 'incarnation' + ]) + expect( + miss( + { incarnationId: 'a' }, + session({ + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'a' }, + terminalSurfaceTombstonesByPaneKey: { + [paneKey]: { + worktreeId: WORKTREE, + parentTabId: 'tab1', + leafId: LEAF, + ptyId: 'pty-1', + incarnationId: 'a', + retiredAt: 1 + } + } + }) + ) + ).toEqual(['tombstone']) + }) + + it('accepts a sibling pane whose tab row names the first pane', () => { + const LEAF_B = '22222222-2222-4222-8222-222222222222' + const state = session({ + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_B, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-1', [LEAF_B]: 'pty-2' } + } + } + }) + expect( + evaluatePtyBindingFastLane( + { ...request, leafId: LEAF_B, ptyId: 'pty-2' }, + state, + WORKTREE, + true + ) + ).toEqual({ eligible: true, misses: [] }) + }) + + it('accepts a matching incarnation', () => { + const state = session({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'a' } }) + expect( + evaluatePtyBindingFastLane({ ...request, incarnationId: 'a' }, state, WORKTREE, true).eligible + ).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.ts new file mode 100644 index 00000000000..9ae6304ed0f --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.ts @@ -0,0 +1,87 @@ +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' + +/** + * Why a reattach can be ineligible. `not_durable` alone means memory already matched but the + * binding was still waiting in the debounced save — the bucket that says whether skipping the + * flush on a durable match is enough, or the autosave itself has to move off the main thread. + */ +export type PtyBindingFastLaneMiss = + | 'split' + | 'legacy_leaf' + | 'tab_missing' + | 'tab_pty' + | 'layout_missing' + | 'leaf_absent' + | 'leaf_pty' + | 'incarnation' + | 'tombstone' + | 'not_durable' + +export type PtyBindingFastLaneRequest = { + tabId: string + leafId: string + ptyId: string + incarnationId?: string + expectedSourceBinding?: unknown +} + +export type PtyBindingFastLaneVerdict = { + eligible: boolean + misses: PtyBindingFastLaneMiss[] +} + +/** + * True only when `persistPtyBinding` would change nothing: the requested binding is already the + * in-memory session's binding and that session is already on disk. Every miss falls through to + * the write path, so the predicate must be at least as strict as the mutations it stands in for. + */ +export function evaluatePtyBindingFastLane( + args: PtyBindingFastLaneRequest, + session: WorkspaceSessionState, + bindingWorktreeId: string, + durable: boolean +): PtyBindingFastLaneVerdict { + const misses: PtyBindingFastLaneMiss[] = [] + const paneKey = `${args.tabId}:${args.leafId}` + if (args.expectedSourceBinding !== undefined) { + misses.push('split') + } + if (!isTerminalLeafId(args.leafId)) { + misses.push('legacy_leaf') + } + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId + ) + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (!tab) { + misses.push('tab_missing') + } else if ( + tab.ptyId !== tabRowPtyIdAfterLeafBinding(tab, layout?.ptyIdsByLeafId, args.leafId, args.ptyId) + ) { + misses.push('tab_pty') + } + if (!layout || !layout.root) { + misses.push('layout_missing') + } else { + if (!layoutContainsLeafId(layout.root, args.leafId)) { + misses.push('leaf_absent') + } + if (layout.ptyIdsByLeafId?.[args.leafId] !== args.ptyId) { + misses.push('leaf_pty') + } + } + // Strict: undefined on both sides matches, undefined on one side does not. + if (session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.incarnationId) { + misses.push('incarnation') + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + misses.push('tombstone') + } + if (!durable) { + misses.push('not_durable') + } + return { eligible: misses.length === 0, misses } +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 6167687e065..27d62c8602b 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,5 +1,6 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { cloneLayoutNode, @@ -15,8 +16,45 @@ import type { PtyBindingSourceExpectation } from './store' import type { StoreRuntimeState } from './store-runtime-state' import type { SessionHostPartitionOperations } from './session-host-partitions' import { resolveHostId } from './session-host-partitions' +import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' +import { ptyBindingIsRefused } from './pty-binding-refusals' +import { startPtyBindingSpan, type PtyBindingOrigin } from './pty-binding-span' +import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' -type PtyBindingPersistenceOperationsRuntime = Pick +type PtyBindingPersistenceOperationsRuntime = Pick< + StoreRuntimeState, + | 'flushOrThrow' + | 'lastDurableWriteGeneration' + | 'pendingWrite' + | 'quitFlushStarted' + | 'state' + | 'writeGeneration' + | 'writeTimer' +> + +type PersistPtyBindingArgs = { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation + /** Set by host-initiated creates, which have no renderer session writer behind them. */ + hostAdmittedMembership?: boolean + /** + * Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer + * and must be able to mint the surface it is binding. A reattach is the opposite: the pane + * either still exists or the user closed it, so creating one grafts back a tab they closed. + * Callers pass false only once absence is meaningful; see the relay's reattach bind. + */ + mayCreate?: boolean + /** Reattach must not revive a surface a prior build durably recorded as retired. */ + mayReviveRetiredSurface?: boolean + /** Span metadata only; see `PtyBindingOrigin`. The write path never reads it. */ + origin?: PtyBindingOrigin +} const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') type PtyBindingPersistenceOperationsContext = { @@ -34,235 +72,199 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - persistPtyBinding( - args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - startupCwd?: string - expectedBinding?: { ptyId: string; incarnationId?: string } - expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean - /** - * Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer - * and must be able to mint the surface it is binding. A reattach is the opposite: the pane - * either still exists or the user closed it, so creating one grafts back a tab they closed. - * Callers pass false only once absence is meaningful; see the relay's reattach bind. - */ - mayCreate?: boolean - /** Reattach must not revive a surface a prior build durably recorded as retired. */ - mayReviveRetiredSurface?: boolean - }, - hostId?: string | null - ): boolean { + persistPtyBinding(args: PersistPtyBindingArgs, hostId?: string | null): boolean { + const runtime = this[ptyBindingPersistenceOperationsContext].runtime const resolvedHostId = resolveHostId(hostId) const session = this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) const paneKey = `${args.tabId}:${args.leafId}` const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - if (args.expectedSourceBinding) { - const expected = args.expectedSourceBinding - if (expected.tabId !== args.tabId) { - return false - } - const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId - ) - const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] - const sourcePaneKey = `${expected.tabId}:${expected.leafId}` - if ( - !sourceTab || - sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || - !layoutContainsLeafId(sourceLayout.root, expected.leafId) || - (expected.incarnationId !== undefined && - session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) - ) { - return false - } - } - if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId - ) - const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] - if ( - !tab || - boundPtyId !== args.expectedBinding.ptyId || - session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId - ) { - return false - } - } - // Decided before any mutation so a refusal leaves nothing half-written. Mirrors the four - // creating branches below — mint a tab, mint a root leaf, split the root and graft a leaf, - // mint a layout — each of which sets `terminalMembershipChanged`. - if ( - args.mayReviveRetiredSurface === false && - session.terminalSurfaceTombstonesByPaneKey?.[paneKey] - ) { + const span = startPtyBindingSpan({ + hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', + origin: args.origin ?? 'unknown', + savePending: runtime.writeTimer !== null || runtime.pendingWrite !== null, + generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration + }) + if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { + span.finish('refused') return false } - if (args.mayCreate === false) { - const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId - ) - const existingLayout = session.terminalLayoutsByTabId?.[args.tabId] - const wouldCreateTopology = - !existingTab || - (isTerminalLeafId(args.leafId) && - (!existingLayout || - !existingLayout.root || - !layoutContainsLeafId(existingLayout.root, args.leafId))) - if (wouldCreateTopology) { - return false - } + // A durable reattach needs neither a session clone nor whole-state serialization. + const verdict = evaluatePtyBindingFastLane( + args, + session, + bindingWorktreeId, + !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) + span.setEligibility(verdict) + if (verdict.eligible) { + span.finish('fast_lane') + return true } + try { + writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) + } catch (err) { + span.finish('threw', err) + throw err + } + span.finish('flushed') + return true + } +} + +function writePtyBinding( + owner: PtyBindingPersistenceOperations, + args: PersistPtyBindingArgs, + session: WorkspaceSessionState, + resolvedHostId: ReturnType, + bindingWorktreeId: string, + paneKey: string +): void { + const runtime = owner[ptyBindingPersistenceOperationsContext].runtime + const sessionBeforeBinding = cloneWorkspaceSessionState(session) + try { if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { - ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, [resolvedHostId]: session } } - const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const reconciledIncarnation = - args.expectedBinding !== undefined && - args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - const restoreSession = (): void => { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSession = - sessionBeforeBinding - } else { - this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { - ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = args.ptyId + applyPtyBinding(args, session, bindingWorktreeId, paneKey) + runtime.flushOrThrow() + } catch (err) { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = sessionBeforeBinding } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding } } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - try { - this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } - } - advanceTopologyFence() - try { - this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true + throw err } } +function applyPtyBinding( + args: PersistPtyBindingArgs, + session: WorkspaceSessionState, + bindingWorktreeId: string, + paneKey: string +): void { + const reconciledIncarnation = + args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = tabRowPtyIdAfterLeafBinding( + tab, + session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, + args.leafId, + args.ptyId + ) + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + return + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() +} + export function installPtyBindingPersistenceOperationsContext( target: object, source: PtyBindingPersistenceOperations diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts new file mode 100644 index 00000000000..3c61056bfca --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -0,0 +1,83 @@ +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import type { PtyBindingSourceExpectation } from './store' + +export type PtyBindingRefusalRequest = { + tabId: string + leafId: string + expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation + mayCreate?: boolean + mayReviveRetiredSurface?: boolean +} + +/** + * The four fences a binding must clear before anything is mutated, so a refusal leaves nothing + * half-written. Order matters: every `false` here is returned before the write path or the + * fast lane can run, which is what the relay's lease expiry and the stable-owner throw rely on. + */ +export function ptyBindingIsRefused( + args: PtyBindingRefusalRequest, + session: WorkspaceSessionState, + bindingWorktreeId: string, + paneKey: string +): boolean { + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return true + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return true + } + } + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return true + } + } + // Mirrors the four creating branches of the write path — mint a tab, mint a root leaf, split + // the root and graft a leaf, mint a layout — each of which sets `terminalMembershipChanged`. + if ( + args.mayReviveRetiredSurface === false && + session.terminalSurfaceTombstonesByPaneKey?.[paneKey] + ) { + return true + } + if (args.mayCreate === false) { + const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId + ) + const existingLayout = session.terminalLayoutsByTabId?.[args.tabId] + const wouldCreateTopology = + !existingTab || + (isTerminalLeafId(args.leafId) && + (!existingLayout || + !existingLayout.root || + !layoutContainsLeafId(existingLayout.root, args.leafId))) + if (wouldCreateTopology) { + return true + } + } + return false +} diff --git a/src/main/persistence/loading-store/pty-binding-span.test.ts b/src/main/persistence/loading-store/pty-binding-span.test.ts new file mode 100644 index 00000000000..1b926ca8257 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-span.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { + _resetPtyBindingSpanSamplingForTests, + PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW, + spawnCommitBindingOrigin, + startPtyBindingSpan +} from './pty-binding-span' + +let records: unknown[] + +beforeEach(() => { + records = [] + setActiveSink({ + push: (record) => { + records.push(record) + }, + flush: () => {}, + close: () => {} + }) + _resetPtyBindingSpanSamplingForTests() + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) +}) + +afterEach(() => { + vi.useRealTimers() + _resetTracerForTests() +}) + +function finishFastLane(): void { + const span = startPtyBindingSpan({ + hostKind: 'local', + origin: 'reattach', + savePending: false, + generationGap: 0 + }) + span.setEligibility({ eligible: true, misses: [] }) + span.finish('fast_lane') +} + +describe('persistence.pty-binding span', () => { + it('labels adopted relay bindings as reattach without an isReattach flag', () => { + expect(spawnCommitBindingOrigin({ agentSessionEnsure: { disposition: 'adopted' } })).toBe( + 'reattach' + ) + }) + + it('records the entry counters, eligibility, and outcome', () => { + const span = startPtyBindingSpan({ + hostKind: 'ssh', + origin: 'spawn', + savePending: true, + generationGap: 2 + }) + span.setEligibility({ eligible: false, misses: ['tab_pty', 'not_durable'] }) + span.finish('flushed') + + expect(records).toHaveLength(1) + expect(records[0]).toHaveProperty('name', 'persistence.pty-binding') + expect(records[0]).toMatchObject({ + attributes: { + 'binding.host': 'ssh', + 'binding.origin': 'spawn', + 'binding.save_pending': true, + 'binding.generation_gap': 2, + 'binding.eligible': false, + 'binding.misses': 'tab_pty,not_durable', + 'binding.outcome': 'flushed' + } + }) + }) + + it('records a throw as a failed span', () => { + const span = startPtyBindingSpan({ + hostKind: 'local', + origin: 'reattach', + savePending: false, + generationGap: 0 + }) + span.finish('threw', new Error('disk full')) + + expect(records[0]).toHaveProperty('exit._tag', 'Failure') + expect(records[0]).toHaveProperty(['attributes', 'binding.outcome'], 'threw') + }) + + it('caps fast-lane spans per window without dropping writes', () => { + for (let i = 0; i < PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW + 5; i++) { + finishFastLane() + } + expect(records).toHaveLength(PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW) + + // Flushed spans are never dropped, even inside a saturated window. + const span = startPtyBindingSpan({ + hostKind: 'local', + origin: 'reattach', + savePending: false, + generationGap: 0 + }) + span.finish('flushed') + expect(records).toHaveLength(PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW + 1) + + vi.setSystemTime(1_700_000_000_000 + 60_000) + finishFastLane() + expect(records).toHaveLength(PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW + 2) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts new file mode 100644 index 00000000000..58bed424a51 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -0,0 +1,92 @@ +import { startSpan } from '../../observability/tracer' +import type { PtyBindingFastLaneMiss } from './pty-binding-fast-lane' + +export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw' + +/** + * Who asked for the bind. `persistPtyBinding` cannot tell a fresh spawn from a warm remount, and + * fresh spawns always flush, so a rate over all calls understates the reattach hit rate. Metadata + * only: nothing in the write path may branch on it. + */ +export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown' + +/** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */ +export function spawnCommitBindingOrigin( + commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } }, + expectedSourceBinding?: unknown +): PtyBindingOrigin { + if (expectedSourceBinding !== undefined) { + return 'split' + } + return commit.isReattach === true || commit.agentSessionEnsure?.disposition === 'adopted' + ? 'reattach' + : 'spawn' +} + +// Bound frequent no-op traces; writes, refusals, and failures are always recorded. +export const PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW = 200 +const FAST_LANE_WINDOW_MS = 60_000 + +let fastLaneWindow: { startMs: number; emitted: number } | null = null + +function admitFastLaneSpan(nowMs: number): boolean { + if (!fastLaneWindow || nowMs - fastLaneWindow.startMs >= FAST_LANE_WINDOW_MS) { + fastLaneWindow = { startMs: nowMs, emitted: 0 } + } + if (fastLaneWindow.emitted >= PTY_BINDING_FAST_LANE_SPAN_BUDGET_PER_WINDOW) { + return false + } + fastLaneWindow.emitted += 1 + return true +} + +export type PtyBindingSpan = { + setEligibility(verdict: { eligible: boolean; misses: readonly PtyBindingFastLaneMiss[] }): void + finish(outcome: PtyBindingSpanOutcome, error?: unknown): void +} + +/** + * One `persistence.pty-binding` span per `persistPtyBinding` call. Attributes are all + * low-cardinality on purpose: no pane key, PTY id, worktree id, path, or SSH target id ever lands + * in the trace file. A local-only NDJSON lane, collected only into a user-submitted bundle. + */ +export function startPtyBindingSpan(entry: { + hostKind: 'local' | 'ssh' | 'runtime' + origin: PtyBindingOrigin + savePending: boolean + generationGap: number +}): PtyBindingSpan { + const span = startSpan('persistence.pty-binding', { + attributes: { + kind: 'persistence', + 'binding.host': entry.hostKind, + 'binding.origin': entry.origin, + 'binding.save_pending': entry.savePending, + 'binding.generation_gap': entry.generationGap + }, + shouldRecord(record) { + if (record.attributes['binding.outcome'] !== 'fast_lane') { + return true + } + return admitFastLaneSpan(Date.now()) + } + }) + return { + setEligibility(verdict) { + span.setAttribute('binding.eligible', verdict.eligible) + span.setAttribute('binding.misses', verdict.misses.join(',')) + }, + finish(outcome, error) { + span.setAttribute('binding.outcome', outcome) + if (outcome === 'threw') { + span.fail(error instanceof Error ? error : String(error)) + return + } + span.end() + } + } +} + +export function _resetPtyBindingSpanSamplingForTests(): void { + fastLaneWindow = null +} diff --git a/src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts b/src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts new file mode 100644 index 00000000000..730eca1a8dd --- /dev/null +++ b/src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' + +const LEAF_A = 'leaf-a' +const LEAF_B = 'leaf-b' + +describe('tabRowPtyIdAfterLeafBinding', () => { + it('fills a null row', () => { + expect(tabRowPtyIdAfterLeafBinding({ ptyId: null }, undefined, LEAF_A, 'pty-1')).toBe('pty-1') + expect(tabRowPtyIdAfterLeafBinding({ ptyId: null }, {}, LEAF_A, 'pty-1')).toBe('pty-1') + }) + + it('follows a respawn of the leaf the row already names', () => { + expect( + tabRowPtyIdAfterLeafBinding({ ptyId: 'pty-1' }, { [LEAF_A]: 'pty-1' }, LEAF_A, 'pty-1b') + ).toBe('pty-1b') + }) + + it('leaves the row on the first pane when a sibling pane binds', () => { + expect( + tabRowPtyIdAfterLeafBinding( + { ptyId: 'pty-1' }, + { [LEAF_A]: 'pty-1', [LEAF_B]: 'pty-2' }, + LEAF_B, + 'pty-2' + ) + ).toBe('pty-1') + // The sibling's first bind, before its leaf is in the map, must not steal the row either. + expect( + tabRowPtyIdAfterLeafBinding({ ptyId: 'pty-1' }, { [LEAF_A]: 'pty-1' }, LEAF_B, 'pty-2') + ).toBe('pty-1') + }) + + it('preserves a non-null row until the renderer clears or replaces it', () => { + expect( + tabRowPtyIdAfterLeafBinding({ ptyId: 'pty-gone' }, { [LEAF_A]: 'pty-1' }, LEAF_B, 'pty-2') + ).toBe('pty-gone') + expect(tabRowPtyIdAfterLeafBinding({ ptyId: 'pty-gone' }, undefined, LEAF_A, 'pty-1')).toBe( + 'pty-gone' + ) + }) +}) diff --git a/src/main/persistence/loading-store/terminal-tab-pty-ownership.ts b/src/main/persistence/loading-store/terminal-tab-pty-ownership.ts new file mode 100644 index 00000000000..d353309bf35 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-tab-pty-ownership.ts @@ -0,0 +1,25 @@ +import type { TerminalTab } from '../../../shared/terminal-tab-types' + +type LeafPtyIds = Readonly> | undefined + +/** + * A tab row names one PTY, but a split tab holds several panes. The renderer keeps the row on + * the first pane and refuses to let later split-pane spawns steal it (see terminal-pty-bindings.ts), + * because a remount reattaches the tab to whatever the row says. Main must agree, or every + * sibling pane's reattach rewrites the row and the two sides ping-pong forever. + * + * The row is rewritten only when it is null or points at the PTY this leaf is replacing. + * A missing leaf is not evidence that a non-null row can be reassigned. + */ +export function tabRowPtyIdAfterLeafBinding( + tab: Pick, + ptyIdsByLeafId: LeafPtyIds, + leafId: string, + ptyId: string +): string { + const current = tab.ptyId + if (current === null || current === ptyIdsByLeafId?.[leafId]) { + return ptyId + } + return current +} diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 72873500bf1..6760a27821c 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -445,7 +445,8 @@ describe('SshRelaySession reconnect incarnation ordering', () => { leafId: INCARNATION_LEAF_ID, ptyId: APP_PTY_ID, incarnationId, - mayReviveRetiredSurface: false + mayReviveRetiredSurface: false, + origin: 'relay_reattach' }) expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(mockStore.markSshRemotePtyLeasesAttachedAsync).mock.invocationCallOrder[0]! diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 4c3a801f097..ef33f2ecadd 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2765,7 +2765,8 @@ export class SshRelaySession { ptyId: appPtyId, incarnationId, ...(mayCreate ? {} : { mayCreate: false }), - mayReviveRetiredSurface: false + mayReviveRetiredSurface: false, + origin: 'relay_reattach' }) if (bound === false) { // Topology absence alone is not authority to kill a process, but neither refusal may