diff --git a/docs/audits/terminal-wait-leading-blank/README.md b/docs/audits/terminal-wait-leading-blank/README.md new file mode 100644 index 00000000000..959fde7c9af --- /dev/null +++ b/docs/audits/terminal-wait-leading-blank/README.md @@ -0,0 +1,23 @@ +# Terminal wait tail-window termination + +`startOfLastNonBlankLines` loops indefinitely if its input begins with a newline and contains fewer nonblank rows than requested. Once the backward cursor reaches zero, JavaScript `lastIndexOf` clamps its negative start position to zero and rediscovers the same first newline. The cursor stops advancing. + +The fix ends the scan when the cursor reaches zero and returns the existing short-tail offset, zero. It changes no prompt patterns or readiness rules. The ordinary finite-window selection cases retain their previous offsets. + +## Reproduce + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/terminal-wait-leading-blank/reproduce.mjs > /tmp/orca-terminal-wait-leading-blank.json +``` + +The script bundles actual source and reverses only the two-line termination change for the baseline. Each case runs in an isolated child with a two-second deadline and 128 MiB heap limit. The parent confirms child termination; no app windows open. Five direct helper/detector cases time out before and return after. A sufficient-row control and actual headless terminal projection controls pass in both variants. Results include source hashes, platform, timing, and an explicit v1.4.198 helper comparison. + +The regression suite uses a separate child for inputs that could hang the worker. It also tests exact row offsets with intervening whitespace, trailing blanks and tails shorter than the requested window. Fifty helper/detector tests pass. + +## Production and incident limits + +The helper is byte-identical in v1.4.198. However, all inspected main/provider/renderer visible-screen projection routes pass through `visibleNonBlankTerminalLines`, and ordinary retained-tail construction removes blank rows too. The real headless producer control confirms this filtering. Calling the public detector directly with a leading newline is therefore insufficient evidence that those production routes trigger the defect. + +A clipped 300-character preview can start at a newline. The preview fallback also preserves it when passed empty retained rows; the proof records both facts. It does not establish an actual application lifecycle that combines that fallback with a live detector call. That remains unproven. + +This is a defensive termination fix found during the memory audit. The loop itself does not allocate a growing collection. No memory magnitude was measured, and it is not an attribution of #19768's main-process growth or #19831's application-scope OOM. diff --git a/docs/audits/terminal-wait-leading-blank/reproduce.mjs b/docs/audits/terminal-wait-leading-blank/reproduce.mjs new file mode 100644 index 00000000000..6ee47701120 --- /dev/null +++ b/docs/audits/terminal-wait-leading-blank/reproduce.mjs @@ -0,0 +1,249 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { build } from 'esbuild' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.') +} + +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const sourcePath = 'src/main/runtime/terminal-wait-tail-window.ts' +const absoluteSource = resolve(root, sourcePath) +const current = await readFile(absoluteSource, 'utf8') +const loop = ' while (lineEnd > 0) {' +const end = ' lineEnd = lineStart - 1\n }\n return 0\n}' +if (current.split(loop).length !== 2 || current.split(end).length !== 2) { + throw new Error('Source changed; review the baseline transform.') +} +const baseline = current + .replace(loop, ' for (;;) {') + .replace(end, ' lineEnd = lineStart - 1\n }\n}') +const sha256 = (value) => createHash('sha256').update(value).digest('hex') +const supportingSources = [ + 'src/main/runtime/terminal-wait-detection.ts', + 'src/main/runtime/orca-runtime-terminal-projection.ts', + 'src/main/runtime/terminal-tail-read.ts', + 'src/main/runtime/terminal-tail-state.ts', + 'src/main/runtime/terminal-wait-tail-state.ts', + 'src/main/daemon/headless-emulator.ts', + 'src/main/runtime/terminal-wait-tail-window.test.ts' +] +const supportingSourceHashes = Object.fromEntries( + await Promise.all( + supportingSources.map(async (path) => [path, sha256(await readFile(resolve(root, path)))]) + ) +) +const scratch = await mkdtemp(join(tmpdir(), 'orca-terminal-wait-blank-')) +const require = createRequire(import.meta.url) +let runnerId + +try { + const runnerPath = join(scratch, 'run-process.cjs') + await build({ + entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')], + outfile: runnerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + runnerId = require.resolve(runnerPath) + const { runProcess } = require(runnerId) + const entry = ` + import { startOfLastNonBlankLines } from './src/main/runtime/terminal-wait-tail-window'; + import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './src/main/runtime/terminal-wait-detection'; + import { HeadlessEmulator } from './src/main/daemon/headless-emulator'; + import { projectTerminalVisibleLines, projectTerminalTailLines } from './src/main/runtime/orca-runtime-terminal-projection'; + import { buildPreview } from './src/main/runtime/terminal-tail-state'; + import { buildTerminalWaitText } from './src/main/runtime/terminal-wait-tail-state'; + const input = JSON.parse(process.argv[2]); + async function main() { + process.stdout.write(JSON.stringify({ phase: 'entered', mode: input.mode }) + '\\n'); + let value; + if (input.mode === 'window') value = startOfLastNonBlankLines(input.text, input.count); + if (input.mode === 'blocked') value = detectTerminalWaitBlockedReason(input.text); + if (input.mode === 'ready') value = isKnownReadyPromptPreview(input.text); + if (input.mode === 'producer-controls') { + const emulator = new HeadlessEmulator({ cols: 80, rows: 12, scrollback: 0 }); + try { + await emulator.write('\\r\\nordinary output\\r\\n'); + const raw = emulator.getVisibleLines(); + const visible = projectTerminalVisibleLines(emulator).lines; + const tail = projectTerminalTailLines(emulator, 12).lines; + const longLines = ['prefix', 'x'.repeat(299)]; + const preview = buildPreview(longLines, ''); + const waitText = buildTerminalWaitText(longLines, '', preview); + value = { + rawRowsStartBlank: raw[0] === '', + visibleRows: visible, + projectedTail: tail, + visibleClassification: detectTerminalWaitBlockedReason(visible.join('\\n')), + ordinaryTailClassification: detectTerminalWaitBlockedReason(buildTerminalWaitText(raw, '', '')), + clippedPreviewStartsNewline: preview.startsWith('\\n'), + retainedTailStartsNewline: waitText.startsWith('\\n'), + retainedTailClassification: detectTerminalWaitBlockedReason(waitText), + emptyTailFallbackStartsNewline: buildTerminalWaitText([], '', preview).startsWith('\\n') + }; + } finally { emulator.dispose(); } + } + process.stdout.write(JSON.stringify({ phase: 'returned', value }) + '\\n'); + } + main().catch(error => { process.stderr.write(String(error)); process.exitCode = 1; }); + ` + const cases = [ + { name: 'leading newline only', mode: 'window', text: '\n', count: 12, expected: 0 }, + { name: 'leading newline and text', mode: 'window', text: '\ntext', count: 12, expected: 0 }, + { name: 'blank screen classification', mode: 'blocked', text: '\n\n', expected: null }, + { + name: 'leading blank trust dialog', + mode: 'blocked', + text: '\nDo you trust this workspace directory?\n1. Yes\n2. No', + expected: 'agent-trust-workspace' + }, + { + name: 'leading blank ready header', + mode: 'ready', + text: '\nOpenAI Codex\nmodel: test\ndirectory: /workspace', + expected: true + }, + { + name: 'enough nonblank rows', + mode: 'window', + text: '\nfirst\nsecond', + count: 1, + expected: 7 + }, + { + name: 'production producer controls', + mode: 'producer-controls', + expected: { + rawRowsStartBlank: true, + visibleRows: ['ordinary output'], + projectedTail: ['ordinary output'], + visibleClassification: null, + ordinaryTailClassification: null, + clippedPreviewStartsNewline: true, + retainedTailStartsNewline: false, + retainedTailClassification: null, + emptyTailFallbackStartsNewline: true + } + } + ] + const results = {} + for (const [label, source] of Object.entries({ before: baseline, after: current })) { + const childPath = join(scratch, `${label}.cjs`) + await build({ + stdin: { contents: entry, resolveDir: root }, + outfile: childPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', + plugins: [ + { + name: 'terminal-wait-baseline', + setup(builder) { + builder.onLoad({ filter: /terminal-wait-tail-window\.ts$/ }, (args) => + resolve(args.path) === absoluteSource ? { contents: source, loader: 'ts' } : null + ) + } + } + ] + }) + results[label] = [] + for (const input of cases) { + let childTerminated = false + const started = performance.now() + const result = await runProcess({ + program: process.execPath, + args: ['--max-old-space-size=128', childPath, JSON.stringify(input)], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 2_000, + maxOutputBytes: 8192, + onChildTerminated: () => { + childTerminated = true + } + }) + const output = result.stdout + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + const expectedTimeout = label === 'before' && cases.indexOf(input) < 5 + const returned = output.find((event) => event.phase === 'returned') + if ( + !childTerminated || + result.timedOut !== expectedTimeout || + !output.some((event) => event.phase === 'entered') + ) { + throw new Error(`Unexpected ${label} result for ${input.name}: ${JSON.stringify(result)}`) + } + if ( + !expectedTimeout && + (result.code !== 0 || + !returned || + ('expected' in input && !isDeepStrictEqual(returned.value, input.expected))) + ) { + throw new Error( + `Unexpected ${label} output for ${input.name}: ${result.stdout} ${result.stderr}` + ) + } + results[label].push({ + name: input.name, + timedOut: result.timedOut, + childTerminated, + code: result.code, + signal: result.signal, + elapsedMs: Math.round(performance.now() - started), + ...(returned ? { value: returned.value } : {}) + }) + } + } + const tag = await runProcess({ + program: 'git', + args: ['show', `v1.4.198:${sourcePath}`], + cwd: root, + maxOutputBytes: 16_384 + }) + const provenance = await runProcess({ + program: 'git', + args: ['rev-parse', 'HEAD'], + cwd: root, + maxOutputBytes: 1024 + }) + process.stdout.write( + `${JSON.stringify( + { + node: process.version, + platform: process.platform, + architecture: process.arch, + revision: provenance.stdout.trim(), + source: sourcePath, + hashes: { + before: sha256(baseline), + after: sha256(current), + reportedVersion: tag.code === 0 ? sha256(tag.stdout) : null + }, + supportingSourceHashes, + reportedVersionSourceMatchesBaseline: tag.code === 0 && tag.stdout === baseline, + childTimeoutMs: 2000, + childHeapLimitMiB: 128, + scope: + 'Helper termination and producer controls; no incident attribution or retained-byte claim.', + results + }, + null, + 2 + )}\n` + ) +} finally { + if (runnerId) { + delete require.cache[runnerId] + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/terminal-wait-leading-blank/results.json b/docs/audits/terminal-wait-leading-blank/results.json new file mode 100644 index 00000000000..98776e8b5b8 --- /dev/null +++ b/docs/audits/terminal-wait-leading-blank/results.json @@ -0,0 +1,172 @@ +{ + "node": "v26.6.0", + "platform": "darwin", + "architecture": "arm64", + "revision": "9558152a04c499c666192dd08906be8ada09e1dc", + "source": "src/main/runtime/terminal-wait-tail-window.ts", + "hashes": { + "before": "e6bc10e924d45bf4bfbd6fabf4404f160107cf7235b11c4e140cd6c78cff9146", + "after": "8df53ea8f92461549d727f251dae7cb4415d4208228ec5f60df9dcf13b863b86", + "reportedVersion": "e6bc10e924d45bf4bfbd6fabf4404f160107cf7235b11c4e140cd6c78cff9146" + }, + "supportingSourceHashes": { + "src/main/runtime/terminal-wait-detection.ts": "226d09d2d8f7f1d692fb8ba1ed340818e6bd402b74d7bd98d86b1868a09503f0", + "src/main/runtime/orca-runtime-terminal-projection.ts": "70b815cf26864719b30b64845c0035093c16b9d85cb4bb8d25c4ae5fa63c3026", + "src/main/runtime/terminal-tail-read.ts": "3517bb22b9bf4bdf2f3acee8ceac9ca71221964cfa3f16be1f7b2ad22212b476", + "src/main/runtime/terminal-tail-state.ts": "a6f41a683d5f03023e4f6d20d653ebf069b036f82100c908a775a1890c49b2ef", + "src/main/runtime/terminal-wait-tail-state.ts": "61acc15fb8b9ce7faa0a2df80a7b6bae09dfed103c15b9c6c35a66d6ad585db1", + "src/main/daemon/headless-emulator.ts": "ace96102285c3967cd9544e7696fe73e1ac6df576b476939aa9819f5c298bab6", + "src/main/runtime/terminal-wait-tail-window.test.ts": "0faab1ca7b01e43ab555fd7fe2979975b818c22f25eca4f74dea3eb04f02c66b" + }, + "reportedVersionSourceMatchesBaseline": true, + "childTimeoutMs": 2000, + "childHeapLimitMiB": 128, + "scope": "Helper termination and producer controls; no incident attribution or retained-byte claim.", + "results": { + "before": [ + { + "name": "leading newline only", + "timedOut": true, + "childTerminated": true, + "code": null, + "signal": "SIGTERM", + "elapsedMs": 2004 + }, + { + "name": "leading newline and text", + "timedOut": true, + "childTerminated": true, + "code": null, + "signal": "SIGTERM", + "elapsedMs": 2002 + }, + { + "name": "blank screen classification", + "timedOut": true, + "childTerminated": true, + "code": null, + "signal": "SIGTERM", + "elapsedMs": 2002 + }, + { + "name": "leading blank trust dialog", + "timedOut": true, + "childTerminated": true, + "code": null, + "signal": "SIGTERM", + "elapsedMs": 2002 + }, + { + "name": "leading blank ready header", + "timedOut": true, + "childTerminated": true, + "code": null, + "signal": "SIGTERM", + "elapsedMs": 2002 + }, + { + "name": "enough nonblank rows", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 32, + "value": 7 + }, + { + "name": "production producer controls", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 36, + "value": { + "rawRowsStartBlank": true, + "visibleRows": ["ordinary output"], + "projectedTail": ["ordinary output"], + "visibleClassification": null, + "ordinaryTailClassification": null, + "clippedPreviewStartsNewline": true, + "retainedTailStartsNewline": false, + "retainedTailClassification": null, + "emptyTailFallbackStartsNewline": true + } + } + ], + "after": [ + { + "name": "leading newline only", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 34, + "value": 0 + }, + { + "name": "leading newline and text", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 33, + "value": 0 + }, + { + "name": "blank screen classification", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 33, + "value": null + }, + { + "name": "leading blank trust dialog", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 33, + "value": "agent-trust-workspace" + }, + { + "name": "leading blank ready header", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 33, + "value": true + }, + { + "name": "enough nonblank rows", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 32, + "value": 7 + }, + { + "name": "production producer controls", + "timedOut": false, + "childTerminated": true, + "code": 0, + "signal": null, + "elapsedMs": 37, + "value": { + "rawRowsStartBlank": true, + "visibleRows": ["ordinary output"], + "projectedTail": ["ordinary output"], + "visibleClassification": null, + "ordinaryTailClassification": null, + "clippedPreviewStartsNewline": true, + "retainedTailStartsNewline": false, + "retainedTailClassification": null, + "emptyTailFallbackStartsNewline": true + } + } + ] + } +} diff --git a/src/main/runtime/terminal-wait-tail-window.test.ts b/src/main/runtime/terminal-wait-tail-window.test.ts new file mode 100644 index 00000000000..22a71140ee8 --- /dev/null +++ b/src/main/runtime/terminal-wait-tail-window.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { build } from 'esbuild' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { startOfLastNonBlankLines } from './terminal-wait-tail-window' + +let scratch = '' +let childPath = '' + +beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'orca-tail-window-')) + childPath = join(scratch, 'leading-blank.cjs') + await build({ + stdin: { + contents: ` + import { startOfLastNonBlankLines } from './src/main/runtime/terminal-wait-tail-window'; + import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './src/main/runtime/terminal-wait-detection'; + const tails = ['', '\\n', '\\n\\n', '\\ntext', '\\ntext\\n', '\\n \\t\\ntext\\n\\n']; + process.stdout.write(JSON.stringify({ + offsets: tails.map(value => startOfLastNonBlankLines(value, 12)), + blank: detectTerminalWaitBlockedReason('\\n\\n'), + ordinary: detectTerminalWaitBlockedReason('\\nordinary output'), + blocked: detectTerminalWaitBlockedReason('\\nDo you trust this workspace directory?\\n1. Yes\\n2. No'), + ready: isKnownReadyPromptPreview('\\nOpenAI Codex\\nmodel: test\\ndirectory: /workspace') + })); + `, + resolveDir: process.cwd() + }, + outfile: childPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterAll(async () => { + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +describe('terminal wait nonblank tail window', () => { + it('terminates on leading blank rows before classifying the remaining screen', async () => { + // Isolate the synchronous regression so its timeout cannot block the test worker. + const result = await runProcess({ + program: process.execPath, + args: ['--max-old-space-size=64', childPath], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 2_000, + maxOutputBytes: 4096 + }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + offsets: [0, 0, 0, 0, 0, 0], + blank: null, + ordinary: null, + blocked: 'agent-trust-workspace', + ready: true + }) + }) + + it.each([ + { value: 'first\nsecond\nthird', count: 2, expected: 'second\nthird' }, + { value: 'first\n\n \t\nsecond\nthird\n', count: 2, expected: 'second\nthird\n' }, + { value: '\nfirst\nsecond', count: 1, expected: 'second' }, + { value: '\nfirst\nsecond', count: 2, expected: 'first\nsecond' }, + { value: 'first\nsecond', count: 3, expected: 'first\nsecond' }, + { value: 'first\nsecond\n\n', count: 1, expected: 'second\n\n' }, + { value: ' \t\r\nsecond', count: 2, expected: ' \t\r\nsecond' } + ])('selects the last $count nonblank rows of $value', ({ value, count, expected }) => { + expect(value.slice(startOfLastNonBlankLines(value, count))).toBe(expected) + }) +}) diff --git a/src/main/runtime/terminal-wait-tail-window.ts b/src/main/runtime/terminal-wait-tail-window.ts index 788000328f1..fec141f7cde 100644 --- a/src/main/runtime/terminal-wait-tail-window.ts +++ b/src/main/runtime/terminal-wait-tail-window.ts @@ -23,7 +23,7 @@ export function startOfLastLines(value: string, count: number): number { export function startOfLastNonBlankLines(value: string, count: number): number { let seen = 0 let lineEnd = value.length - for (;;) { + while (lineEnd > 0) { const lineStart = value.lastIndexOf('\n', lineEnd - 1) + 1 if (hasNonWhitespaceBetween(value, lineStart, lineEnd)) { seen += 1 @@ -36,6 +36,7 @@ export function startOfLastNonBlankLines(value: string, count: number): number { } lineEnd = lineStart - 1 } + return 0 } function hasNonWhitespaceBetween(value: string, start: number, end: number): boolean {