mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/131-crashes
# Conflicts: # src/renderer/src/store/slices/store-cascades.test.ts
This commit is contained in:
@@ -41,6 +41,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
version_suffix:
|
||||
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -192,6 +197,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
||||
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -366,6 +372,18 @@ jobs:
|
||||
fi
|
||||
new="${base}-rc.$((highest_rc + 1))"
|
||||
fi
|
||||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||||
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
||||
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
||||
# branch builds never outrank the main RC series and cannot
|
||||
# hijack the update channel; clients find them by matching the
|
||||
# identifier ("perf") in the prerelease components.
|
||||
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
|
||||
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
new="${new}.${VERSION_SUFFIX}"
|
||||
fi
|
||||
;;
|
||||
patch|minor|major)
|
||||
new="$(bump "$latest_stable" "$KIND")"
|
||||
@@ -808,7 +826,10 @@ jobs:
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
# Why the optional trailing identifier: suffixed side-branch RCs
|
||||
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
||||
# identity as plain RCs.
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
|
||||
@@ -96,7 +96,10 @@ jobs:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
# Why the optional trailing identifier: suffixed side-branch RCs
|
||||
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
||||
# identity as plain RCs.
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
|
||||
@@ -122,3 +122,6 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ko-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ if (reportPaths.length === 0) {
|
||||
const BUDGETS = {
|
||||
maxMedianKeyLatencyMs: 75,
|
||||
maxWorstKeyLatencyMs: 300,
|
||||
maxRevisitLatencyMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
maxScrollLatencyMs: 150,
|
||||
maxRestoreLatencyMs: 1000,
|
||||
@@ -80,6 +81,12 @@ function validateRow(row) {
|
||||
BUDGETS.maxWorstKeyLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'revisit latency',
|
||||
parseMs(row.revisit, 'revisit', row, failures),
|
||||
BUDGETS.maxRevisitLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'timer drift',
|
||||
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
|
||||
@@ -113,6 +120,14 @@ function validateRow(row) {
|
||||
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
|
||||
BUDGETS.maxRendererDroppedBacklogs
|
||||
)
|
||||
// Why: parked-memory rows carry heap/view-count metrics with no latency
|
||||
// budget; recognize them so memory-only scenarios pass the gate instead of
|
||||
// tripping the "no recognized budget metrics" guard.
|
||||
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
|
||||
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
|
||||
checkedMetricCount += 1
|
||||
}
|
||||
}
|
||||
if (checkedMetricCount === 0) {
|
||||
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
'frames=180',
|
||||
'median=2.9ms',
|
||||
'worst=5.9ms',
|
||||
'revisit=42.0ms',
|
||||
'maxTimerDrift=12.1ms',
|
||||
'scroll=149.9ms',
|
||||
'restore=642.0ms',
|
||||
@@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
'frames=60',
|
||||
'median=76.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'maxTimerDrift=151.0ms',
|
||||
'scroll=151.0ms',
|
||||
'restore=1001.0ms',
|
||||
@@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
|
||||
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
|
||||
@@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
expect(result.stderr).toContain('no recognized budget metrics found')
|
||||
})
|
||||
|
||||
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
|
||||
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('fails OpenCode annotation rows that contain no budget metrics', () => {
|
||||
const reportPath = writeReport('panes=1 frames=60')
|
||||
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
budgetFailures,
|
||||
collectTerminalPerfRows,
|
||||
compareScenarios,
|
||||
escapeHtml,
|
||||
formatLargeValue,
|
||||
formatMs,
|
||||
readJsonReport,
|
||||
scenarioTitle
|
||||
} from './terminal-perf-report-rows.mjs'
|
||||
import { basename, dirname } from 'node:path'
|
||||
|
||||
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
// Why: every tracked metric is lower-is-better, so delta coloring and the
|
||||
// regression table share one direction rule.
|
||||
const MS_METRICS = [
|
||||
{ key: 'medianMs', label: 'Typing median', chart: true },
|
||||
{ key: 'worstMs', label: 'Typing worst', chart: true },
|
||||
{ key: 'scrollMs', label: 'Active scroll', chart: true },
|
||||
{ key: 'restoreMs', label: 'Restore', chart: true },
|
||||
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
|
||||
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
|
||||
]
|
||||
|
||||
const COUNT_METRICS = [
|
||||
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
|
||||
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
|
||||
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
|
||||
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
|
||||
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
|
||||
// Why: parked-memory scenarios are table-only — heap/view counts have no
|
||||
// ms trend story, so they stay out of the charts.
|
||||
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
|
||||
{ key: 'liveTerminals', label: 'Live xterm instances' },
|
||||
{ key: 'livePaneManagers', label: 'Live pane managers' }
|
||||
]
|
||||
|
||||
const SERIES_COLORS = {
|
||||
medianMs: '#2563eb',
|
||||
worstMs: '#dc2626',
|
||||
scrollMs: '#d97706',
|
||||
restoreMs: '#7c3aed',
|
||||
revisitMs: '#0d9488'
|
||||
}
|
||||
|
||||
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
|
||||
|
||||
export function parseHtmlReportArgs(argv, env = process.env) {
|
||||
const args = [...argv]
|
||||
if (args[0] === '--') {
|
||||
args.shift()
|
||||
}
|
||||
|
||||
const inputs = []
|
||||
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--output' || arg === '-o') {
|
||||
const next = args[index + 1]
|
||||
if (!next || next.startsWith('-')) {
|
||||
throw new Error(`${arg} requires a path`)
|
||||
}
|
||||
outputPath = next
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--output=')) {
|
||||
outputPath = arg.slice('--output='.length)
|
||||
continue
|
||||
}
|
||||
const labeled = arg.match(LABELED_INPUT_RE)
|
||||
if (labeled) {
|
||||
inputs.push({ label: labeled[1], path: labeled[2] })
|
||||
} else {
|
||||
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length === 0) {
|
||||
throw new Error(
|
||||
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
|
||||
)
|
||||
}
|
||||
return { inputs, outputPath }
|
||||
}
|
||||
|
||||
// ── Trend data ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildMatrix(revisions) {
|
||||
const scenarios = new Map()
|
||||
for (const revision of revisions) {
|
||||
for (const row of revision.rows) {
|
||||
if (!scenarios.has(row.scenario)) {
|
||||
scenarios.set(row.scenario, new Map())
|
||||
}
|
||||
scenarios.get(row.scenario).set(revision.label, row)
|
||||
}
|
||||
}
|
||||
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
|
||||
return { scenarios, orderedScenarios }
|
||||
}
|
||||
|
||||
function niceCeil(value) {
|
||||
if (value <= 0) {
|
||||
return 1
|
||||
}
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
for (const step of [1, 2, 2.5, 5, 10]) {
|
||||
if (value <= step * magnitude) {
|
||||
return step * magnitude
|
||||
}
|
||||
}
|
||||
return 10 * magnitude
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderTrendChart({ scenario, byRevision, revisions, title }) {
|
||||
const metrics = MS_METRICS.filter(
|
||||
(metric) =>
|
||||
metric.chart &&
|
||||
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
|
||||
)
|
||||
if (metrics.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const width = 560
|
||||
const height = 230
|
||||
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
|
||||
const plotW = width - pad.left - pad.right
|
||||
const plotH = height - pad.top - pad.bottom
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...metrics.flatMap((metric) =>
|
||||
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
|
||||
)
|
||||
)
|
||||
const yMax = niceCeil(maxValue * 1.15)
|
||||
const xFor = (index) =>
|
||||
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
|
||||
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
|
||||
|
||||
const parts = []
|
||||
parts.push(
|
||||
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
|
||||
)
|
||||
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
|
||||
// Horizontal gridlines + y labels
|
||||
const ticks = 4
|
||||
for (let tick = 0; tick <= ticks; tick += 1) {
|
||||
const value = (yMax * tick) / ticks
|
||||
const y = yFor(value)
|
||||
parts.push(
|
||||
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
|
||||
)
|
||||
parts.push(
|
||||
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
// X labels
|
||||
revisions.forEach((revision, index) => {
|
||||
parts.push(
|
||||
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
|
||||
)
|
||||
})
|
||||
// Series
|
||||
for (const metric of metrics) {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
const points = revisions
|
||||
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
|
||||
.filter((point) => point.value != null)
|
||||
if (points.length === 0) {
|
||||
continue
|
||||
}
|
||||
const path = points
|
||||
.map(
|
||||
(point, order) =>
|
||||
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
|
||||
)
|
||||
.join(' ')
|
||||
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
|
||||
for (const point of points) {
|
||||
const x = xFor(point.index)
|
||||
const y = yFor(point.value)
|
||||
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
|
||||
parts.push(
|
||||
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
}
|
||||
parts.push('</svg>')
|
||||
|
||||
const legend = metrics
|
||||
.map((metric) => {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
|
||||
})
|
||||
.join('')
|
||||
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
|
||||
}
|
||||
|
||||
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
|
||||
if (baseline == null || latest == null) {
|
||||
return '<td class="delta">—</td>'
|
||||
}
|
||||
const diff = latest - baseline
|
||||
const pct = baseline === 0 ? null : (diff / baseline) * 100
|
||||
let cls = 'neutral'
|
||||
if (zeroBudget) {
|
||||
cls = latest > 0 ? 'worse' : 'better'
|
||||
} else if (pct != null && Math.abs(pct) >= 5) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
} else if (baseline === 0 && diff !== 0) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
}
|
||||
const pctLabel =
|
||||
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
|
||||
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
|
||||
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
|
||||
}
|
||||
|
||||
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
|
||||
const metricRows = []
|
||||
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
|
||||
for (const metric of allMetrics) {
|
||||
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
|
||||
if (values.every((value) => value == null)) {
|
||||
continue
|
||||
}
|
||||
const isMs = MS_METRICS.includes(metric)
|
||||
const format = isMs ? formatMs : formatLargeValue
|
||||
const cells = values
|
||||
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
|
||||
.join('')
|
||||
const baseline = values.find((value) => value != null)
|
||||
const latest = values.toReversed().find((value) => value != null)
|
||||
metricRows.push(
|
||||
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
|
||||
zeroBudget: metric.key === 'rendererDroppedBacklogs'
|
||||
})}</tr>`
|
||||
)
|
||||
}
|
||||
if (metricRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
|
||||
return `<section class="scenario-block">
|
||||
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
|
||||
<tbody>${metricRows.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderHeadline(revisions, matrix) {
|
||||
if (revisions.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const first = revisions[0]
|
||||
const last = revisions.at(-1)
|
||||
const cards = []
|
||||
for (const scenario of matrix.orderedScenarios) {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const baseRow = byRevision.get(first.label)
|
||||
const lastRow = byRevision.get(last.label)
|
||||
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
|
||||
continue
|
||||
}
|
||||
const diff = lastRow.medianMs - baseRow.medianMs
|
||||
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
|
||||
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
|
||||
cards.push(`<div class="card ${cls}">
|
||||
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
|
||||
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
|
||||
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
|
||||
</div>`)
|
||||
}
|
||||
if (cards.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
|
||||
}
|
||||
|
||||
function renderBudgets(latestRevision) {
|
||||
const failures = []
|
||||
for (const row of latestRevision.rows) {
|
||||
for (const failure of budgetFailures(row)) {
|
||||
failures.push(`${row.scenario}: ${failure}`)
|
||||
}
|
||||
}
|
||||
const status =
|
||||
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
|
||||
const failureList =
|
||||
failures.length === 0
|
||||
? ''
|
||||
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
|
||||
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
|
||||
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
|
||||
}
|
||||
|
||||
function renderInputsMeta(revisions) {
|
||||
const items = revisions
|
||||
.map((revision) => {
|
||||
const stats = revision.stats
|
||||
const statsLabel = stats
|
||||
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
|
||||
: ''
|
||||
const failNote =
|
||||
stats && stats.unexpected > 0
|
||||
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
|
||||
: ''
|
||||
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
|
||||
})
|
||||
.join('')
|
||||
return `<ol class="inputs">${items}</ol>`
|
||||
}
|
||||
|
||||
function renderRawDetails(revisions) {
|
||||
return revisions
|
||||
.map((revision) => {
|
||||
const rows = revision.rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
|
||||
)
|
||||
.join('')
|
||||
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></details>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const PAGE_CSS = `
|
||||
:root { color-scheme: light; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
h2 { font-size: 18px; margin: 28px 0 10px; }
|
||||
h3 { font-size: 15px; margin: 18px 0 6px; }
|
||||
.meta { color: #64748b; font-size: 13px; }
|
||||
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
|
||||
.meta-warn { color: #b45309; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
|
||||
.card.better { border-left-color: #16a34a; }
|
||||
.card.worse { border-left-color: #dc2626; }
|
||||
.card.neutral { border-left-color: #94a3b8; }
|
||||
.card-title { font-size: 12px; color: #64748b; }
|
||||
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
|
||||
.card-sub { font-size: 11px; color: #94a3b8; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
|
||||
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
|
||||
.trend-chart { width: 100%; height: auto; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
|
||||
.gridline { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.axis-label { font-size: 10px; fill: #64748b; }
|
||||
.point-label { font-size: 10px; font-weight: 600; }
|
||||
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.legend-unit { color: #94a3b8; margin-left: auto; }
|
||||
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
|
||||
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
|
||||
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
|
||||
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
|
||||
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
|
||||
td.delta.better { color: #15803d; font-weight: 600; }
|
||||
td.delta.worse { color: #b91c1c; font-weight: 600; }
|
||||
td.delta.neutral { color: #64748b; }
|
||||
.delta-abs { font-weight: 400; color: #94a3b8; }
|
||||
.pass { color: #15803d; font-weight: 700; }
|
||||
.fail { color: #b91c1c; font-weight: 700; }
|
||||
details { margin: 8px 0; }
|
||||
summary { cursor: pointer; font-size: 13px; color: #334155; }
|
||||
`
|
||||
|
||||
function renderHtml({ generatedAt, revisions }) {
|
||||
const matrix = buildMatrix(revisions)
|
||||
const charts =
|
||||
revisions.length >= 2
|
||||
? matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderTrendChart({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
: ''
|
||||
const tables = matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderScenarioTable({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Terminal Performance Over Time</title>
|
||||
<style>${PAGE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Terminal Performance Over Time</h1>
|
||||
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
|
||||
${renderInputsMeta(revisions)}
|
||||
${renderHeadline(revisions, matrix)}
|
||||
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
|
||||
<section><h2>Metric detail by scenario</h2>${tables}</section>
|
||||
${renderBudgets(revisions.at(-1))}
|
||||
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function generateTerminalPerfHtmlReport({
|
||||
inputs,
|
||||
inputPaths,
|
||||
outputPath,
|
||||
now = new Date()
|
||||
}) {
|
||||
// Why: older callers (the scale report gate) pass bare inputPaths.
|
||||
const resolvedInputs =
|
||||
inputs ??
|
||||
(inputPaths ?? []).map((path) => ({
|
||||
label: basename(path).replace(/\.json$/i, ''),
|
||||
path
|
||||
}))
|
||||
const revisions = resolvedInputs.map(({ label, path }) => {
|
||||
const report = readJsonReport(path)
|
||||
return {
|
||||
label,
|
||||
path,
|
||||
stats: report.stats ?? null,
|
||||
rows: collectTerminalPerfRows(report, label)
|
||||
}
|
||||
})
|
||||
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
|
||||
if (totalRows === 0) {
|
||||
throw new Error('No opencode terminal perf annotations found in the provided reports')
|
||||
}
|
||||
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, html)
|
||||
const latestFailures = revisions
|
||||
.at(-1)
|
||||
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
|
||||
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.filename === process.argv[1]
|
||||
if (isMain) {
|
||||
try {
|
||||
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
|
||||
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
|
||||
console.log(
|
||||
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateTerminalPerfHtmlReport,
|
||||
parseHtmlReportArgs
|
||||
} from './generate-terminal-perf-html-report.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeReport(
|
||||
annotationDescription,
|
||||
annotationType = 'opencode-scale-same-workspace-25',
|
||||
reportName = 'report.json'
|
||||
) {
|
||||
const dir = makeTempDir()
|
||||
const reportPath = join(dir, reportName)
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: annotationType,
|
||||
description: annotationDescription
|
||||
},
|
||||
{
|
||||
type: 'browser-unrelated',
|
||||
description: 'median=999.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('generate-terminal-perf-html-report', () => {
|
||||
it('parses labeled and bare input paths plus output flags', () => {
|
||||
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
|
||||
inputs: [
|
||||
{ label: 'a', path: 'a.json' },
|
||||
{ label: 'b', path: 'b.json' }
|
||||
],
|
||||
outputPath: 'out.html'
|
||||
})
|
||||
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
|
||||
{
|
||||
inputs: [
|
||||
{ label: 'main', path: 'runs/0-main.json' },
|
||||
{ label: '#5038 final', path: 'runs/4-final.json' }
|
||||
],
|
||||
outputPath: 'test-results/terminal-perf-impact-report.html'
|
||||
}
|
||||
)
|
||||
expect(
|
||||
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
|
||||
).toEqual({
|
||||
inputs: [{ label: 'a', path: 'a.json' }],
|
||||
outputPath: 'env.html'
|
||||
})
|
||||
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
|
||||
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('writes a single-run report with scenario tables and budget status', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=25',
|
||||
'frames=60',
|
||||
'median=12.4ms',
|
||||
'worst=44.8ms',
|
||||
'revisit=28.6ms',
|
||||
'scroll=61.0ms',
|
||||
'restore=320.0ms',
|
||||
'maxTimerDrift=8.0ms',
|
||||
'rendererPeakQueuedChars=2048',
|
||||
'mainPeakInFlightChars=4096',
|
||||
'heldAckChars=1024',
|
||||
'hiddenSkippedChars=512',
|
||||
'rendererDroppedBacklogs=0'
|
||||
].join(' ')
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath,
|
||||
now: new Date('2026-06-09T10:00:00.000Z')
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('Terminal Performance Over Time')
|
||||
expect(html).toContain('2026-06-09T10:00:00.000Z')
|
||||
expect(html).toContain('Same workspace panes — 25 panes')
|
||||
expect(html).toContain('opencode-scale-same-workspace-25')
|
||||
expect(html).toContain('28.6ms')
|
||||
expect(html).toContain('Pass')
|
||||
// Why: one run has no over-time story; the trend section must not render.
|
||||
expect(html).not.toContain('Trends across revisions')
|
||||
expect(html).not.toContain('browser-unrelated')
|
||||
})
|
||||
|
||||
it('renders parked-memory heap and live view counts as table metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
|
||||
expect(result.budgetFailureCount).toBe(0)
|
||||
expect(html).toContain('Parked hidden terminal memory — 8 panes')
|
||||
expect(html).toContain('Renderer JS heap (MB)')
|
||||
expect(html).toContain('142.5')
|
||||
expect(html).toContain('Live xterm instances')
|
||||
expect(html).toContain('Live pane managers')
|
||||
})
|
||||
|
||||
it('marks over-budget rows as failures for the latest run', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=100',
|
||||
'median=80.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'rendererPeakQueuedChars=2097153',
|
||||
'rendererDroppedBacklogs=1'
|
||||
].join(' '),
|
||||
'opencode-scale-cross-workspace-100'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.budgetFailureCount).toBe(5)
|
||||
expect(html).toContain('Fail')
|
||||
expect(html).toContain('medianMs 80 > 75')
|
||||
expect(html).toContain('Cross-workspace hidden panes')
|
||||
})
|
||||
|
||||
it('renders ordered revisions with trend charts and baseline deltas', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const middleReport = writeReport(
|
||||
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'backpressure.json'
|
||||
)
|
||||
const finalReport = writeReport(
|
||||
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'final.json'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'backpressure', path: middleReport },
|
||||
{ label: 'final', path: finalReport }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.rowCount).toBe(3)
|
||||
expect(html).toContain('Baseline vs latest')
|
||||
expect(html).toContain('Trends across revisions')
|
||||
expect(html).toContain('trend-chart')
|
||||
expect(html).toContain('>main<')
|
||||
expect(html).toContain('>backpressure<')
|
||||
expect(html).toContain('>final<')
|
||||
// Why: median 50 -> 20 is a 60% improvement and must read as better.
|
||||
expect(html).toContain('delta better')
|
||||
expect(html).toContain('-60%')
|
||||
expect(html).toContain('50.0ms → 20.0ms')
|
||||
})
|
||||
|
||||
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const finalReport = makeTempDir()
|
||||
const finalPath = join(finalReport, 'final.json')
|
||||
writeFileSync(
|
||||
finalPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-25',
|
||||
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
|
||||
},
|
||||
{
|
||||
type: 'opencode-revisit-pressure',
|
||||
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'final', path: finalPath }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(html).toContain('Revisit under pressure')
|
||||
expect(html).toContain('<td>—</td>')
|
||||
})
|
||||
|
||||
it('fails when reports contain no terminal perf annotations', () => {
|
||||
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
|
||||
|
||||
expect(() =>
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath: join(makeTempDir(), 'report.html')
|
||||
})
|
||||
).toThrow('No opencode terminal perf annotations found')
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,11 @@ export function rcNumberFromTag(base, tag) {
|
||||
}
|
||||
|
||||
const suffix = tag.slice(prefix.length)
|
||||
return /^\d+$/.test(suffix) ? Number(suffix) : null
|
||||
// Why the optional .identifier: suffixed side-branch RCs (v1.2.3-rc.4.perf)
|
||||
// must advance the shared rc counter, or the next suffixed cut recomputes
|
||||
// an existing tag and the workflow refuses to re-cut over it.
|
||||
const match = /^(\d+)(?:\.[0-9A-Za-z]+)?$/.exec(suffix)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
export function rcNumberFromReleaseSubject(base, subject) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Entry point for the multi-workspace typing-latency bench
|
||||
* (tests/e2e/terminal-multi-workspace-typing-latency.spec.ts).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \
|
||||
* --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix]
|
||||
*
|
||||
* Results land in tools/benchmarks/results/multi-workspace-typing-*.json.
|
||||
* Run once per build/config with distinct --label values, then diff the
|
||||
* totalMs/inputHalfMs/echoHalfMs percentiles.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
|
||||
const knobByFlag = {
|
||||
'--panes': 'ORCA_TYPING_BENCH_LOAD_PANES',
|
||||
'--rate-kbps': 'ORCA_TYPING_BENCH_RATE_KBPS',
|
||||
'--keys': 'ORCA_TYPING_BENCH_KEYS',
|
||||
'--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS',
|
||||
'--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS',
|
||||
'--label': 'ORCA_TYPING_BENCH_LABEL'
|
||||
}
|
||||
|
||||
const env = { ...process.env, ORCA_TYPING_BENCH: '1' }
|
||||
const passthroughArgs = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--') {
|
||||
continue
|
||||
}
|
||||
const knob = knobByFlag[argv[i]]
|
||||
if (knob) {
|
||||
env[knob] = argv[++i]
|
||||
} else {
|
||||
passthroughArgs.push(argv[i])
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
npxCommand,
|
||||
[
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/terminal-multi-workspace-typing-latency.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...passthroughArgs
|
||||
],
|
||||
{ stdio: 'inherit', env }
|
||||
)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json'
|
||||
const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
export function parseReportGateArgs(argv, env = process.env) {
|
||||
const forwardedArgs = [...argv]
|
||||
@@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({
|
||||
spawnSyncImpl,
|
||||
env
|
||||
)
|
||||
return exitCode(budgetResult)
|
||||
const budgetExitCode = exitCode(budgetResult)
|
||||
if (budgetExitCode !== 0) {
|
||||
return budgetExitCode
|
||||
}
|
||||
|
||||
const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH
|
||||
const htmlResult = runNodeScript(
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
[reportPath, '--output', htmlReportPath],
|
||||
'inherit',
|
||||
spawnSyncImpl,
|
||||
env
|
||||
)
|
||||
return exitCode(htmlResult)
|
||||
}
|
||||
|
||||
if (process.argv[1] === import.meta.filename) {
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
expect(calls.map((call) => call.args[0])).toEqual([
|
||||
'config/scripts/run-terminal-scale-perf-e2e.mjs',
|
||||
'config/scripts/summarize-terminal-perf-report.mjs',
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs'
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs',
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs'
|
||||
])
|
||||
expect(calls[0].args).toEqual([
|
||||
'config/scripts/run-terminal-scale-perf-e2e.mjs',
|
||||
@@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs',
|
||||
reportPath
|
||||
])
|
||||
expect(calls[3].args).toEqual([
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
reportPath,
|
||||
'--output',
|
||||
'test-results/terminal-perf-impact-report.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the report path from env when no flag is provided', () => {
|
||||
@@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath])
|
||||
})
|
||||
|
||||
it('uses the HTML report path from env when provided', () => {
|
||||
const reportPath = tempReportPath()
|
||||
const { calls, spawnSyncImpl } = makeSpawnSync()
|
||||
|
||||
const status = runTerminalScalePerfReportGate({
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html',
|
||||
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath
|
||||
},
|
||||
spawnSyncImpl
|
||||
})
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(calls[3].args).toEqual([
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
reportPath,
|
||||
'--output',
|
||||
'tmp/terminal-report.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the report when Playwright clears the target report directory', () => {
|
||||
const reportPath = tempReportPath()
|
||||
const { spawnSyncImpl } = makeSpawnSync({
|
||||
|
||||
@@ -25,6 +25,7 @@ function printMarkdownTable(rows) {
|
||||
['Frames', 'frames'],
|
||||
['Median', 'median'],
|
||||
['Worst', 'worst'],
|
||||
['Revisit', 'revisit'],
|
||||
['Scroll', 'scroll'],
|
||||
['Restore', 'restore'],
|
||||
['Max Drift', 'maxTimerDrift'],
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BUDGETS = {
|
||||
medianMs: 75,
|
||||
worstMs: 300,
|
||||
revisitMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
scrollMs: 150,
|
||||
restoreMs: 1000,
|
||||
rendererQueuedChars: 2 * 1024 * 1024,
|
||||
rendererPeakQueuedChars: 2 * 1024 * 1024,
|
||||
rendererDroppedBacklogs: 0
|
||||
}
|
||||
|
||||
const SCENARIO_LABELS = [
|
||||
['opencode-scale-same-workspace', 'Same workspace panes'],
|
||||
['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'],
|
||||
['opencode-scale-pressure', 'ACK-backpressured PTYs'],
|
||||
['opencode-scale-hidden-pressure', 'Hidden real PTYs'],
|
||||
['opencode-cross-workspace-typing', 'Cross-workspace typing'],
|
||||
['opencode-main-pressure', 'Main renderer pressure'],
|
||||
['opencode-hidden-pressure', 'Hidden pressure'],
|
||||
['opencode-revisit-pressure', 'Revisit under pressure'],
|
||||
// Why: the prefix also matches opencode-parked-memory-disabled, so both
|
||||
// parked-memory scenarios group under one label.
|
||||
['opencode-parked-memory', 'Parked hidden terminal memory']
|
||||
]
|
||||
|
||||
export function readJsonReport(path) {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const start = raw.indexOf('{')
|
||||
const end = raw.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) {
|
||||
throw new Error(`${path}: no JSON object found`)
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function parseAnnotationDescription(description) {
|
||||
const values = {}
|
||||
for (const part of description.split(/\s+/)) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
values[part.slice(0, index)] = part.slice(index + 1)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function collectTerminalPerfRows(report, source) {
|
||||
const rows = []
|
||||
const visitSuite = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const annotation of test.annotations ?? []) {
|
||||
if (!annotation.type.startsWith('opencode-')) {
|
||||
continue
|
||||
}
|
||||
rows.push(
|
||||
normalizeRow({
|
||||
source,
|
||||
scenario: annotation.type,
|
||||
...parseAnnotationDescription(annotation.description ?? '')
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visitSuite(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visitSuite(suite)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function parseMs(value) {
|
||||
const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function parseCount(value) {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const count = Number(value)
|
||||
return Number.isFinite(count) ? count : null
|
||||
}
|
||||
|
||||
function normalizeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
group: scenarioGroup(row.scenario),
|
||||
panes: parseCount(row.panes),
|
||||
frames: parseCount(row.frames),
|
||||
medianMs: parseMs(row.median),
|
||||
worstMs: parseMs(row.worst),
|
||||
revisitMs: parseMs(row.revisit),
|
||||
maxTimerDriftMs: parseMs(row.maxTimerDrift),
|
||||
scrollMs: parseMs(row.scroll),
|
||||
restoreMs: parseMs(row.restore),
|
||||
rendererQueuedChars: parseCount(row.rendererQueuedChars),
|
||||
rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars),
|
||||
rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs),
|
||||
mainPeakPendingChars: parseCount(row.mainPeakPendingChars),
|
||||
mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars),
|
||||
heldAckChars: parseCount(row.heldAckChars),
|
||||
hiddenSkippedChars: parseCount(row.hiddenSkippedChars),
|
||||
// Why: parked-memory annotations report a fractional MB heap figure plus
|
||||
// live renderer view counts; Number() keeps the MB float intact.
|
||||
heapUsedMB: parseCount(row.heapUsedMB),
|
||||
liveTerminals: parseCount(row.liveTerminals),
|
||||
livePaneManagers: parseCount(row.livePaneManagers)
|
||||
}
|
||||
}
|
||||
|
||||
export function scenarioGroup(scenario) {
|
||||
for (const [prefix, label] of SCENARIO_LABELS) {
|
||||
if (scenario.startsWith(prefix)) {
|
||||
return label
|
||||
}
|
||||
}
|
||||
return 'Other terminal scenarios'
|
||||
}
|
||||
|
||||
function scenarioSortKey(scenario) {
|
||||
const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix))
|
||||
const paneMatch = scenario.match(/-(\d+)$/)
|
||||
return [
|
||||
prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex,
|
||||
paneMatch ? Number(paneMatch[1]) : 0,
|
||||
scenario
|
||||
]
|
||||
}
|
||||
|
||||
export function compareScenarios(a, b) {
|
||||
const ka = scenarioSortKey(a)
|
||||
const kb = scenarioSortKey(b)
|
||||
if (ka[0] !== kb[0]) {
|
||||
return ka[0] - kb[0]
|
||||
}
|
||||
if (ka[1] !== kb[1]) {
|
||||
return ka[1] - kb[1]
|
||||
}
|
||||
return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0
|
||||
}
|
||||
|
||||
export function scenarioTitle(scenario, row) {
|
||||
const group = scenarioGroup(scenario)
|
||||
if (row?.panes != null) {
|
||||
return `${group} — ${row.panes} panes`
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
export function budgetFailures(row) {
|
||||
const failures = []
|
||||
for (const [key, budget] of Object.entries(BUDGETS)) {
|
||||
const value = row[key]
|
||||
if (value == null) {
|
||||
continue
|
||||
}
|
||||
if (value > budget) {
|
||||
failures.push(`${key} ${value} > ${budget}`)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
export function formatMs(value) {
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
return `${value.toFixed(1)}ms`
|
||||
}
|
||||
|
||||
export function formatLargeValue(value) {
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(2)}M`
|
||||
}
|
||||
if (value >= 1024) {
|
||||
return `${Math.round(value / 1024)}k`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kill all sessions also closes terminal tabs
|
||||
|
||||
GitHub: https://github.com/stablyai/orca/issues/8001
|
||||
GitHub: https://github.com/stablyai/orca/issues/8001
|
||||
Branch: `bug-kill-all-sessions-doesnt-kill-terminals`
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Terminal Hidden View Parking
|
||||
|
||||
Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch
|
||||
`terminalHiddenViewParking` (default on). See
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the
|
||||
invariants this design extends and the full phase list.
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
|
||||
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
|
||||
memory cost, and it forces every hidden byte through renderer-side write/skip
|
||||
decisions. The main-process model (daemon + runtime headless emulators) already
|
||||
ingests every byte and can serve restorable snapshots, so the renderer view for
|
||||
a long-hidden pane is redundant state.
|
||||
|
||||
A previous attempt shipped and was reverted the same day. The post-mortem
|
||||
finding: parking unmounted the pane component, which also tore down the
|
||||
renderer's PTY byte parsers — and those parsers are the only source of bell
|
||||
notifications, title-transition agent-complete notifications, and tab titles.
|
||||
A parked worktree whose agent finished would never notify. This design keeps
|
||||
those side effects alive while parked.
|
||||
|
||||
## Design
|
||||
|
||||
### Park policy (renderer)
|
||||
|
||||
A pure policy module decides which hidden terminal tabs may park:
|
||||
|
||||
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
|
||||
- Hot-retain working set: recently visible worktrees/tabs are retained
|
||||
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
|
||||
- Eligibility excludes: visible panes, hidden-measuring startup probes,
|
||||
activity-portal panes, tabs with pending startup commands or pending
|
||||
activation spawns, floating-panel tabs, and any tab whose PTY is not
|
||||
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
|
||||
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
|
||||
entirely.
|
||||
|
||||
### Park mechanics
|
||||
|
||||
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
|
||||
renders null for parked tabs). This is the same teardown that tab-group moves
|
||||
already exercise: transports detach but the PTY session, daemon model, and tab
|
||||
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
|
||||
resources are released.
|
||||
|
||||
### Parked watcher (the piece the reverted attempt lacked)
|
||||
|
||||
While a tab is parked, a pane-less watcher
|
||||
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
|
||||
consumption mode is decided once at watcher start:
|
||||
|
||||
- **Main side-effect authority on (default):** the watcher is purely
|
||||
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
|
||||
parses no bytes. Titles, agent working/idle/exited transitions, BEL
|
||||
attention, and PR links arrive as main-tracker facts and drive the same
|
||||
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
|
||||
the watcher marks the PTY hidden so main stops renderer byte delivery
|
||||
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
|
||||
`2031-subscribe` fact and the watcher replies out-of-band via
|
||||
`transport.sendInput`.
|
||||
- **Kill switch off:** the watcher subscribes to raw bytes through the
|
||||
dispatcher sidecar mechanism (the same mechanism background agent launches
|
||||
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
|
||||
titles (all-titles ordering, live-path normalization), the title-transition
|
||||
agent tracker (completion notification, prompt-cache timer), the OSC-aware
|
||||
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
|
||||
byte responder (`parked-terminal-mode2031-responder.ts`, whose
|
||||
`subscribeToPtyData` registration doubles as the delivery-interest signal).
|
||||
|
||||
The two modes drive one shared policy-callback block, so flipping the kill
|
||||
switch never changes notification semantics. Main's synthetic
|
||||
agent-title/permission frames feed the main tracker directly and arrive as
|
||||
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
|
||||
mode.
|
||||
|
||||
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
|
||||
hidden-dropped chunks are answered by main's model responder
|
||||
([`terminal-query-authority.md`](./terminal-query-authority.md)); in
|
||||
kill-switch-off byte mode only the 2031 reply is answered and Command Code
|
||||
output is not scraped, matching the pre-gate status quo.
|
||||
|
||||
### Reveal
|
||||
|
||||
Revealing a parked tab remounts the pane subtree and rides the existing
|
||||
reattach path: fresh xterm via `openTerminal` (unicode provider activation
|
||||
before any write), daemon model snapshot > relay replay > cold restore
|
||||
precedence, replay-guarded so snapshot-embedded queries never answer, then
|
||||
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
|
||||
disposed before the pane handlers re-register.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. PTY reads never stop; parking only changes renderer-side view lifetime.
|
||||
2. Bell, agent-completion, title, and PR-link side effects keep working while
|
||||
parked (watcher parity tests).
|
||||
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
|
||||
table, rendering golden) and accepts input immediately.
|
||||
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
|
||||
or strand parked state.
|
||||
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
|
||||
visible panes.
|
||||
|
||||
## Relation to later phases (all shipped)
|
||||
|
||||
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
|
||||
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
|
||||
stops hidden byte delivery in main, moving the parked 2031 reply from the
|
||||
byte sidecar to the `2031-subscribe` fact; the model query responder
|
||||
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
|
||||
mode survives only behind the kill switches. Parking still excludes
|
||||
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
|
||||
would return as a byte parser only if remote-runtime tabs — whose bytes never
|
||||
transit local main — ever became parkable.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Terminal Model/View Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Terminal output should have one authoritative model path and many disposable
|
||||
views. A renderer xterm is the fast interactive view, but it must not be the
|
||||
only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists.
|
||||
|
||||
This contract defines the boundary the shipped terminal stack implements — and
|
||||
that future terminal work must preserve — without changing the query-response
|
||||
behavior that real shells and TUIs depend on. See [Architecture
|
||||
Status](#architecture-status) for the shipped phases.
|
||||
|
||||
## Terms
|
||||
|
||||
- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY,
|
||||
or remote runtime PTY.
|
||||
- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this
|
||||
is mostly the headless emulator plus retained read transcript state.
|
||||
- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop
|
||||
subscriber, or CLI read page consuming model state and live output.
|
||||
- **Snapshot:** A bounded model serialization that can restore a view without
|
||||
replaying an unbounded byte log.
|
||||
- **Transcript:** The retained output contract for `orca terminal read`; it is
|
||||
line/cursor oriented and distinct from a screen snapshot.
|
||||
|
||||
## Non-Negotiable Invariants
|
||||
|
||||
1. PTY reads do not stop to protect renderer performance. Backpressure may bound
|
||||
delivery to views, but terminal state, notifications, titles, and agent
|
||||
status keep advancing from the PTY stream.
|
||||
2. Active visible terminal input/output stays on the lowest-latency path. Bulk
|
||||
hidden or background output must not delay keystroke-sized foreground redraws.
|
||||
3. Hidden views do not own unbounded output memory. Main's hidden-delivery
|
||||
gate drops renderer-bound bytes for hidden-marked PTYs after model
|
||||
ingestion and emits an out-of-band restore marker
|
||||
(`pty:modelRestoreNeeded`) so the view restores from the model on reveal.
|
||||
With the gate's kill switches off, hidden bytes ride a bounded renderer
|
||||
queue whose overflow latches the same model restore.
|
||||
4. Returning to a hidden or slept terminal must show model-correct output. A
|
||||
stale or replaced view may be cleared and replayed from a snapshot, but it
|
||||
must not show a warning fallback when model recovery is available.
|
||||
5. Snapshots and live bytes have ordering metadata. A view restore must not
|
||||
duplicate bytes already included in the snapshot or drop bytes that arrived
|
||||
after it. Main buffer snapshots report the pending-delivery start sequence
|
||||
(`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a
|
||||
restore without misreading foreign sequence domains as duplicates.
|
||||
6. Terminal query authority is singular and structural: the party that
|
||||
writes a chunk into a live terminal answers its queries. Visible renderer
|
||||
and remote views keep xterm authority. Chunks dropped by the
|
||||
hidden-delivery gate are answered exactly once by the main model
|
||||
responder, from runtime-emulator state plus renderer-pushed view
|
||||
attributes. Replayed, seeded, or snapshot bytes are answered by no one.
|
||||
The daemon emulator never answers. (Amended by Phase 5 — see
|
||||
[`terminal-query-authority.md`](./terminal-query-authority.md).)
|
||||
7. The transcript contract stays separate from screen restore. `orca terminal
|
||||
read` must preserve bounded previews, cursor pagination, partial-line rules,
|
||||
truncation flags, and total counts even if view snapshots change shape.
|
||||
8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy
|
||||
the same model/view contract or explicitly report that model recovery is
|
||||
unavailable.
|
||||
|
||||
## Current Owners
|
||||
|
||||
| Responsibility | Current owner |
|
||||
| --- | --- |
|
||||
| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` |
|
||||
| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` |
|
||||
| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` |
|
||||
| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` |
|
||||
| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` |
|
||||
| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` |
|
||||
| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` |
|
||||
| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` |
|
||||
| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` |
|
||||
| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` |
|
||||
|
||||
## Snapshot Contract
|
||||
|
||||
A model snapshot must include:
|
||||
|
||||
- terminal dimensions used to produce the snapshot;
|
||||
- enough ANSI state to rehydrate xterm before snapshot content;
|
||||
- bounded screen and scrollback content;
|
||||
- title and cwd metadata when known;
|
||||
- source metadata that distinguishes headless/model snapshots from renderer
|
||||
fallback snapshots;
|
||||
- monotonic ordering metadata for live-output reconciliation when available.
|
||||
|
||||
A snapshot must not:
|
||||
|
||||
- include unbounded transcript history;
|
||||
- answer terminal queries while replaying into the model;
|
||||
- overwrite newer live view output with older model output;
|
||||
- hide that recovery was unavailable for a PTY surface.
|
||||
|
||||
## View Contract
|
||||
|
||||
A renderer or remote view may:
|
||||
|
||||
- write active visible output immediately;
|
||||
- budget visible inactive output;
|
||||
- stop receiving hidden output entirely while main's hidden-delivery gate owns
|
||||
the bytes (model restore on reveal);
|
||||
- request fresh snapshots for restore, mobile subscription, or explicit remote
|
||||
snapshot recovery.
|
||||
|
||||
A view must:
|
||||
|
||||
- keep live-output buffers bounded while a snapshot is in flight;
|
||||
- apply generation or sequence checks before replaying a snapshot;
|
||||
- refresh/repaint after replay when xterm/WebGL needs an explicit paint;
|
||||
- keep side effects such as title, bell, cwd, and agent status flowing from the
|
||||
PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte
|
||||
delivery is budgeted, gated, or parked.
|
||||
|
||||
## Transcript Contract
|
||||
|
||||
The retained read transcript is not a screen dump. It must preserve:
|
||||
|
||||
- uncursored bounded latest preview behavior;
|
||||
- cursor reads over completed retained lines;
|
||||
- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`;
|
||||
- partial-line duplication rules;
|
||||
- `truncated`, `limited`, and total count metadata;
|
||||
- bounded memory for long partial lines and large output bursts.
|
||||
|
||||
Snapshot optimizations must be tested against this transcript contract instead
|
||||
of assuming xterm scrollback serialization can replace it.
|
||||
|
||||
## Required Contract Tests
|
||||
|
||||
Before moving more runtime behavior behind the model/view boundary, add or
|
||||
extend tests that prove:
|
||||
|
||||
- headless snapshots rehydrate rich alternate-screen TUI state;
|
||||
- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive
|
||||
queries (the `session.test.ts` pins are permanent);
|
||||
- the main runtime responder answers queries only from live chunks the
|
||||
hidden-delivery gate dropped — never delivered, replayed, seeded, or
|
||||
remote-subscribed chunks;
|
||||
- hidden renderer overflow restores from model state without duplicate live
|
||||
output;
|
||||
- sleep/wake and worktree revisit restore from model-correct state;
|
||||
- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs;
|
||||
- remote runtime multiplex output remains ACK bounded and can request recovery
|
||||
snapshots;
|
||||
- mobile subscribers receive bounded snapshots without unbounded pending live
|
||||
output;
|
||||
- retained terminal reads remain pageable and bounded after large output.
|
||||
|
||||
Current coverage is spread across:
|
||||
|
||||
- `src/main/daemon/headless-emulator.test.ts`
|
||||
- `src/main/daemon/session.test.ts`
|
||||
- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers,
|
||||
`pendingDeliveryStartSeq`)
|
||||
- `src/main/ipc/pty-hidden-delivery-gate.test.ts`
|
||||
- `src/main/runtime/mobile-subscribe-integration.test.ts`
|
||||
- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts`
|
||||
- `src/main/runtime/rpc/terminal-multiplex.test.ts`
|
||||
- `src/main/runtime/orca-runtime.test.ts`
|
||||
- `src/main/runtime/terminal-query-responder.test.ts`
|
||||
- `src/shared/terminal-output-side-effects.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts`
|
||||
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`
|
||||
- `tests/e2e/terminal-hidden-view-parking.spec.ts`
|
||||
- `tests/e2e/terminal-parked-memory.spec.ts`
|
||||
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
|
||||
- `tests/e2e/terminal-output-scheduler.spec.ts`
|
||||
- `tests/e2e/artificial-opencode-terminal-load.spec.ts`
|
||||
|
||||
## Architecture Status
|
||||
|
||||
All six phases of the terminal model/view architecture are shipped; the kill
|
||||
switches noted in parentheses default on:
|
||||
|
||||
1. **Hidden view parking** — "Park hidden terminal views behind a byte
|
||||
watcher": hidden terminal tabs unmount their xterm after a cold-park
|
||||
hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects
|
||||
alive while parked (`terminalHiddenViewParking`). See
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
|
||||
2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory":
|
||||
renderer heap and live-terminal counts gate parking in the perf suite
|
||||
(`tests/e2e/terminal-parked-memory.spec.ts`).
|
||||
3. **Side-effect authority in main** — "Track terminal titles in main with
|
||||
all-titles ordering", "Move terminal side-effect authority to a main facts
|
||||
channel", "Complete terminal side-effect facts coverage", "Finish terminal
|
||||
side-effect authority migration": every local/daemon/SSH PTY byte is
|
||||
side-effect-parsed once in main and delivered as `pty:sideEffect` facts
|
||||
(`terminalMainSideEffectAuthority`). See
|
||||
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md).
|
||||
4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views":
|
||||
main drops renderer-bound bytes for hidden-marked PTYs after model
|
||||
ingestion; delivery-interest registrations exempt sidecar byte consumers,
|
||||
out-of-band restore markers latch model restore, and
|
||||
`pendingDeliveryStartSeq` reconciles live output racing a restore
|
||||
(`terminalHiddenDeliveryGate`).
|
||||
5. **Model query authority** — "Answer hidden terminal queries from the
|
||||
model", "Bridge renderer view attributes to the model responder", "Align
|
||||
query authority contract and spawn-time ownership": hidden-dropped queries
|
||||
are answered by the runtime emulator plus renderer-pushed view attributes,
|
||||
and hidden-at-spawn PTYs are marked before byte one
|
||||
(`terminalModelQueryAuthority`). See
|
||||
[`terminal-query-authority.md`](./terminal-query-authority.md).
|
||||
6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the
|
||||
renderer's per-chunk hidden-skip eligibility grammar and the 10s codex
|
||||
startup query window are deleted; the kill-switch-off fallback is the
|
||||
bounded background queue with overflow-latched model restore.
|
||||
|
||||
Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a
|
||||
local repaint quirk. Renderer fallback paths retire only when their kill
|
||||
switches do, and only after the equivalent model path has platform and TUI
|
||||
golden coverage.
|
||||
@@ -0,0 +1,326 @@
|
||||
# Terminal Query Authority
|
||||
|
||||
Status: Shipped — Phase 5 of the terminal model/view architecture, kill
|
||||
switch `terminalModelQueryAuthority` (default on). Builds on
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this
|
||||
phase **amends invariant 6**),
|
||||
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md)
|
||||
(Phase 3), and the Phase-4 hidden-delivery gate
|
||||
(`src/main/ipc/pty-hidden-delivery-gate.ts`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion
|
||||
(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`).
|
||||
Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks
|
||||
waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang
|
||||
TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4
|
||||
hidden skip latch had the same hole (only mode 2031 and the 10s codex startup
|
||||
window answered), so this is not a regression — it is the long-standing gap
|
||||
this phase closes.
|
||||
|
||||
Contract invariant 6 ("the model must never answer queries") was written
|
||||
against a real bug: the daemon emulator replying ahead of the renderer with
|
||||
default-xterm values (the OSC-11 default-black-background race,
|
||||
`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger
|
||||
was never "the model answers" — it was **two answerers for the same bytes**,
|
||||
one of them with wrong values. Phase 5 keeps the singularity and fixes the
|
||||
values.
|
||||
|
||||
## Decision: the delivery decision is the reply decision
|
||||
|
||||
Main answers a query **iff main dropped the chunk that carried it**. The same
|
||||
per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that
|
||||
decides renderer delivery decides reply ownership, evaluated once,
|
||||
synchronously, at ingestion:
|
||||
|
||||
- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via
|
||||
`Terminal.onData` → `transport.sendInput`, unchanged.
|
||||
- Hidden-marked, no delivery interest → chunk dropped → main answers from the
|
||||
runtime headless emulator, via the provider input path (`provider.write`,
|
||||
same path as `pty:write`; daemon shell-ready write gating and the SSH relay
|
||||
write apply unchanged).
|
||||
- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both
|
||||
sides).
|
||||
|
||||
This is structurally exactly-one-responder: a chunk is delivered or dropped,
|
||||
never both, and each side only answers bytes it actually parsed live. The
|
||||
mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist
|
||||
from Phase 4 and are reused, not duplicated.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Fact-based renderer replies per query class** (the mode-2031 pattern
|
||||
generalized): needs a main-side detection grammar per query, a fact round
|
||||
trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the
|
||||
emulator is the only state for a hidden pane. The 2031 fact stays because it
|
||||
is subscription registration, not a state query.
|
||||
- **Emulator always answers**: re-creates the OSC-11 double-reply race for
|
||||
visible panes. Never.
|
||||
|
||||
## Mechanism: forwarded emulator onData, not a new grammar
|
||||
|
||||
`HeadlessEmulator` has `onData` wiring behind a per-write capture flag.
|
||||
For static and model-state queries, xterm core **is** the query grammar: the
|
||||
runtime emulator runs the same xterm version with equivalent options as the
|
||||
renderer pane, so main's reply set equals the visible renderer's by
|
||||
construction — verified empirically against the bundled headless build:
|
||||
DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including
|
||||
DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS
|
||||
(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching
|
||||
visible behavior today. The headless build has **no theme service**: OSC
|
||||
4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option
|
||||
set, so the view-attribute class is answered by responder-registered parser
|
||||
handlers instead (below) — never by core defaults.
|
||||
|
||||
Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and
|
||||
attached to the emulator `writeChain` link (the mark can flip between
|
||||
ingestion and an async write; the decision must not be re-read at reply time):
|
||||
|
||||
1. gate enabled (`terminalMainSideEffectAuthority` and
|
||||
`terminalHiddenDeliveryGate` both on) AND new kill switch
|
||||
`terminalModelQueryAuthority !== false`;
|
||||
2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData`
|
||||
— same module state, same tick as the drop sites);
|
||||
3. the write is live PTY data — never `seedHeadlessTerminal`,
|
||||
`maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay
|
||||
(main-side replay guard, mirror of the renderer's `replay-guard.ts`);
|
||||
4. no remote view subscriber is attached to the PTY (runtime terminal-RPC
|
||||
subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop
|
||||
xterm receiving the multiplexed stream answers with view authority, exactly
|
||||
like a visible local pane. Legacy JSON `terminal.subscribe` streams **do**
|
||||
register as view subscribers and suppress, even when the consumer is a
|
||||
read-only watcher — deliberately conservative, because the stream may feed
|
||||
an older live xterm view and a withheld reply (the pre-Phase-5 status quo)
|
||||
is strictly safer than a double reply. Consumers that never register a
|
||||
stream (CLI `terminal.read`, automation observers) do not suppress — they
|
||||
also do not answer; that bounded no-reply case matches today's behavior.
|
||||
|
||||
Everything the emulator emits outside a forwarding window is discarded, which
|
||||
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
|
||||
triggered by option mutations).
|
||||
|
||||
## Reply classes
|
||||
|
||||
| Class | Queries | Answer source |
|
||||
| --- | --- | --- |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
|
||||
### View-attribute bridge
|
||||
|
||||
Renderer→main push, `pty:terminalViewAttributes` — one global snapshot,
|
||||
not per-PTY: the composed terminal `ITheme` (from
|
||||
`applyTerminalAppearance`, `terminal-appearance.ts`),
|
||||
`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme
|
||||
mode (`resolveTerminalColorSchemeMode` — the same source as the existing
|
||||
hidden 2031 reply). Pushed on renderer startup and on every theme/settings
|
||||
apply.
|
||||
|
||||
Main consumes it two ways:
|
||||
|
||||
- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options
|
||||
inside the replay guard; xterm core then answers DECRQSS DECSCUSR and
|
||||
DECRQM 12 with renderer-true values (verified working headless).
|
||||
- Palette and color-scheme replies come from responder-registered parser
|
||||
handlers on the emulator (`registerOscHandler` 4/10/11/12,
|
||||
`registerCsiHandler` for DSR ?996n), because the headless core cannot
|
||||
answer them. The OSC handlers see SET payloads too, so runtime OSC
|
||||
4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are
|
||||
tracked per PTY and layered over the pushed base palette — matching what
|
||||
the renderer's theme service reports for a visible pane.
|
||||
|
||||
Staleness rules: replies use the last push; a theme flip is stale for at most
|
||||
one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push).
|
||||
**Before the first push main answers no view-attribute query** — a fabricated
|
||||
default would resurrect the default-black OSC-11 bug; silence is the
|
||||
documented hidden status quo.
|
||||
|
||||
### Kitty keyboard flags
|
||||
|
||||
`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching
|
||||
`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low:
|
||||
for the write-only daemon use, keyboard state never alters serialization; the
|
||||
change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring
|
||||
them, and lets the responder answer `CSI ? u` with the flags the hidden app
|
||||
actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes`
|
||||
for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty
|
||||
flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty
|
||||
reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`)
|
||||
stays authoritative. Slice 3 wires the re-seed consumer: the daemon
|
||||
warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn
|
||||
result into `seedHeadlessTerminal`, which applies them to the fresh runtime
|
||||
emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding
|
||||
window), so hidden `CSI ? u` reports the flags the hidden app actually
|
||||
pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer
|
||||
`?0u`; protocol-conformant programs re-push.
|
||||
|
||||
### ConPTY DA1 variant
|
||||
|
||||
The provider kind is known main-side: mirror `isLocalNativeWindowsPty`
|
||||
(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon
|
||||
provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the
|
||||
emulator parser (the main-side twin of
|
||||
`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by
|
||||
the forwarding predicate. The override is installed at emulator creation and
|
||||
retrofitted when the spawn mark lands (daemon stream data can create the
|
||||
emulator before the awaited spawn response marks the PTY). ConPTY blocking on
|
||||
a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is
|
||||
closed by the slice-3 `initiallyHidden` spawn flag (races section).
|
||||
|
||||
## Suppression: when main never replies
|
||||
|
||||
- Visible or unmarked PTY (chunk was delivered).
|
||||
- Renderer delivery interest registered (chunk was delivered to a sidecar).
|
||||
- Remote-runtime (`remote:`) PTYs — never markable
|
||||
(`isHiddenDeliveryGateManagedPty`), bytes never transit local main.
|
||||
- Remote view subscriber attached (mobile/web/remote desktop owns replies).
|
||||
- Seed/hydration/snapshot writes into the emulator, and option pushes.
|
||||
- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an
|
||||
independent off switch for the responder alone.
|
||||
- The **daemon** emulator: never, under any setting. The responder lives in
|
||||
main's runtime only; `session.test.ts:163-190` stays pinned verbatim.
|
||||
|
||||
## Transition races
|
||||
|
||||
Worst cases, per direction:
|
||||
|
||||
- **visible→hidden**: chunks delivered between the visibility flip and the
|
||||
mark landing in main are hidden-skipped by the renderer write path without
|
||||
query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden
|
||||
skip behavior, bounded by one renderer→main IPC hop. After the mark lands,
|
||||
main answers everything it drops.
|
||||
- **hidden→visible**: unmark consumes the drop latch and emits the restore
|
||||
marker; the snapshot replay is replay-guarded, so queries main already
|
||||
answered are never re-answered from the snapshot; post-unmark live chunks
|
||||
are answered by xterm once (restore-queued live chunks reply late, not
|
||||
twice).
|
||||
- **Split queries across the drop/deliver boundary**: neither parser saw the
|
||||
whole sequence → no reply; the restore marker resets renderer cross-chunk
|
||||
state and replay hygiene resets the parser. At-most-once holds.
|
||||
|
||||
Safe-side rule per class: duplicates are structurally impossible (one decision
|
||||
point per chunk); where the race costs anything it costs a missing reply.
|
||||
That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or
|
||||
tolerate silence, as they did for every hidden pane before this phase). The
|
||||
one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible
|
||||
pane answers it from the renderer xterm. A PTY spawned hidden previously had
|
||||
no answerer until the renderer's hidden mark landed in main (one IPC hop
|
||||
after spawn). Slice 3 closes that window with the `initiallyHidden`
|
||||
spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn`
|
||||
(never for remote-runtime transports), and main marks the PTY hidden before
|
||||
the first byte — pre-spawn for
|
||||
daemon-host sessions whose id is minted up front, immediately after
|
||||
`provider.spawn` resolves otherwise — so the gate and responder own queries
|
||||
from byte one. The pane's first visibility sync then re-marks or unmarks
|
||||
through the existing Phase-4 machinery (unmark emits the restore marker for
|
||||
any spawn-window drops).
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Exactly one party may answer any query, chosen by the chunk's delivery
|
||||
decision: delivered → the consuming live view's xterm; dropped → main's
|
||||
model responder; replayed/seeded → no one. The decision is captured once,
|
||||
synchronously, at ingestion.
|
||||
2. Main answers only from live PTY bytes parsed by the runtime emulator —
|
||||
never from snapshot, seed, hydration, or option-push writes.
|
||||
3. View-attribute answers are renderer-true or absent: no reply is ever
|
||||
fabricated from emulator defaults (the OSC-11 lesson).
|
||||
4. The daemon emulator stays write-only; daemon subprocess query writes stay
|
||||
zero (`session.test.ts` pins are permanent).
|
||||
5. Reply parity is structural for static and model-state classes: same xterm
|
||||
core, equivalent options, no hand-rolled grammar — the only overrides are
|
||||
the documented ConPTY DA1 variant and the view-attribute parser handlers
|
||||
the headless core cannot serve.
|
||||
6. Remote views keep view authority; main yields whenever a remote view
|
||||
subscriber is attached.
|
||||
|
||||
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
|
||||
replaced by:
|
||||
|
||||
> 6. Terminal query authority is singular and structural: the party that
|
||||
> writes a chunk into a live terminal answers its queries. Visible renderer
|
||||
> and remote views keep xterm authority. Chunks dropped by the
|
||||
> hidden-delivery gate are answered exactly once by the main model
|
||||
> responder, from runtime-emulator state plus renderer-pushed view
|
||||
> attributes. Replayed, seeded, or snapshot bytes are answered by no one.
|
||||
> The daemon emulator never answers.
|
||||
|
||||
The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11,
|
||||
or theme-sensitive queries" splits into: daemon emulator never answers
|
||||
(unchanged pins) / runtime responder answers only hidden-dropped chunks. The
|
||||
side-effect authority matrix row "DECSET 2031 reply — query authority stays
|
||||
with the view (contract invariant 6)" gains a pointer here; its reply path is
|
||||
otherwise untouched in this phase.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs
|
||||
interest-suppressed; each reply class; seed/hydrate silence; remote-
|
||||
subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip
|
||||
between ingestion and async emulator write (captured decision wins).
|
||||
- Parity harness: shared query byte fixtures through a renderer-configured
|
||||
xterm (onData capture) and through the responder; assert byte-identical
|
||||
replies for static + model-state classes, and for view-attribute classes
|
||||
after an attribute push.
|
||||
- `session.test.ts:163-190`: assertions stay; the comment is updated to name
|
||||
the main responder (not "the renderer") as the hidden answerer.
|
||||
- E2E: hidden `claude /theme` reports the configured theme; hidden TUI
|
||||
blocked on CPR/DA unblocks while gated; reveal shows no stray reply
|
||||
fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and
|
||||
`terminal-hidden-view-parking.spec.ts` stay green.
|
||||
|
||||
## Cut-offs (shipped as three stacked slices)
|
||||
|
||||
1. **Responder core.** Emulator onData wiring + per-write capture + main
|
||||
replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`);
|
||||
static + model-state classes; ConPTY DA1 override; remote-subscriber
|
||||
suppression; `terminalModelQueryAuthority` switch; unit + parity tests.
|
||||
Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock.
|
||||
2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor
|
||||
option application under the guard, responder OSC/DSR parser handlers with
|
||||
per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e.
|
||||
3. **Contract alignment.** Invariant-6 amendment in the contract doc, test
|
||||
bullet split, `session.test.ts` comment, side-effect matrix pointer, and
|
||||
the Phase 6 prerequisites below recorded as accepted.
|
||||
|
||||
## Phase 6 (delete skip grammar + startup window): prerequisites from this design
|
||||
|
||||
Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s
|
||||
codex startup renderer-query window are deleted. Kill-switch-off hidden panes
|
||||
fall back to the pre-grammar path — hidden bytes ride the bounded background
|
||||
scheduler queue; overflow latches the model-snapshot restore — and never run
|
||||
a per-chunk content scan.
|
||||
|
||||
Accepted and shipped in slice 3 (except where noted):
|
||||
|
||||
- **Mark-before-first-byte** (shipped): panes spawned without a visible view
|
||||
are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn`
|
||||
(spawn-record flag, not a renderer round trip) so startup queries —
|
||||
including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6
|
||||
removed the codex exclusion with the window: codex spawns are main-owned
|
||||
from byte zero too, the responder answering their startup probes.
|
||||
- **Attributes before spawn** (shipped): the renderer pushes composed view
|
||||
attributes once at app start (right after settings load, before terminal
|
||||
reconnect/spawn), so spawn-time view-attribute queries no longer fall into
|
||||
the silent-until-push rule. Per-pane appearance applies keep re-publishing
|
||||
through the same deduped publisher.
|
||||
- **Daemon shell-ready write gating** (verified): responder replies through
|
||||
`ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never
|
||||
dropped, and the queue flushes at the shell-ready marker or the 15s
|
||||
`SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed
|
||||
with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches
|
||||
as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains
|
||||
worth adding.
|
||||
- With the skip grammar deleted, every chunk is either written to a live
|
||||
xterm or dropped — the delivered-but-skipped no-reply gap disappears and
|
||||
the only remaining loss window is the mark IPC race.
|
||||
- **2031 consolidation** (optional follow-up): move the subscription registry
|
||||
into the responder (the headless core cannot serve 997 pushes any more than
|
||||
it can ?996n) and push 997 flips from the attribute cache, retiring the
|
||||
`2031-subscribe` fact reply, the parked responder, and the parked-tab
|
||||
theme-flip gap.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Terminal Side-Effect Authority
|
||||
|
||||
Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch
|
||||
`terminalMainSideEffectAuthority` (default on). Builds on
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1).
|
||||
|
||||
## Problem
|
||||
|
||||
Main parses every local/daemon/SSH PTY byte before renderer delivery
|
||||
(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`:
|
||||
side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL
|
||||
watchers; SSH feeds the same path from `wireUpPtyEvents` in
|
||||
`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects
|
||||
users see — bell unread/notifications, title transitions, agent-complete
|
||||
notifications, command lifecycle, PR links — were derived a second time by
|
||||
renderer byte parsers. That duplication forced Phase 1's watcher to parse
|
||||
bytes, forced main to fabricate synthetic OSC title frames over `pty:data`
|
||||
just so renderer parsers could see them, and blocked Phase 4 from ever
|
||||
stopping hidden byte delivery. Phase 3 made main the side-effect parser for
|
||||
every PTY whose bytes transit local main; the renderer byte parsers
|
||||
(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte
|
||||
mode) survive only for remote-runtime PTYs and the kill-switch-off fallback.
|
||||
|
||||
## Authority Matrix
|
||||
|
||||
"Main" means parsed once in `onPtyData` and delivered as derived facts.
|
||||
Remote-runtime PTYs (`remote:`) never transit local main; the renderer
|
||||
(`remote-runtime-pty-transport.ts:74`) stays their parser permanently.
|
||||
|
||||
| Side effect | local-daemon | SSH | remote-runtime |
|
||||
| --- | --- | --- | --- |
|
||||
| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) |
|
||||
| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer |
|
||||
| BEL attention (OSC-aware stateful detector) | main | main | renderer |
|
||||
| OSC 133;D command-finished exit code | main | main | renderer |
|
||||
| GitHub PR-link scan | main | main | renderer |
|
||||
| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer |
|
||||
| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer |
|
||||
| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer |
|
||||
|
||||
## Main-Side Tracker
|
||||
|
||||
- The side-effect core shared with the renderer processor lives in
|
||||
`src/shared/terminal-output-side-effects.ts`: all-titles ordering via
|
||||
`extractAllOscTitles` (coalesced working→idle transitions are why last-title
|
||||
is insufficient — issue #1083), `normalizeTerminalTitle`, the literal
|
||||
`cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`,
|
||||
`src/shared/agent-detection.ts`), the `createAgentStatusTracker`
|
||||
transitions, the stale-working-title 3s timer
|
||||
(`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector
|
||||
(`src/shared/terminal-bell-detector.ts`).
|
||||
- One tracker per PTY on `OrcaRuntimeService`, lazily created like
|
||||
`agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the
|
||||
stale-title timer).
|
||||
- It replaced the chunk-level last-title extraction in `onPtyData`: titles
|
||||
feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters,
|
||||
and pending-message delivery see intermediate transitions instead of only
|
||||
the chunk's last title. PTY/leaf records keep the **raw** last title
|
||||
(worktree `ps` and mobile tab titles expect raw); emitted facts carry
|
||||
`(normalizedTitle, rawTitle)` like `onTitleChange`.
|
||||
- No deferred drain in main — the renderer's setTimeout(0) batching
|
||||
(`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which
|
||||
does not exist in main. Main applies synchronously and batches the IPC per
|
||||
flush.
|
||||
- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own
|
||||
last-title scan, untouched: synthetic titles must never reach it.
|
||||
|
||||
## Event Transport: `pty:sideEffect`
|
||||
|
||||
One batched main→renderer channel (`window.api.pty.onSideEffect`,
|
||||
`src/preload/index.ts`). It is **not** routed through the pty dispatcher:
|
||||
the renderer fact-consumer registry
|
||||
(`terminal-side-effect-facts-handler.ts`) subscribes directly via
|
||||
`window.api.pty.onSideEffect` — one channel subscription per renderer, with
|
||||
exactly one registered fact consumer per PTY. Events are **facts, not
|
||||
decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title),
|
||||
`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries
|
||||
`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf
|
||||
records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY
|
||||
`outputSequence`.
|
||||
|
||||
Ordering rules:
|
||||
|
||||
1. Per-PTY in-order; facts from one chunk are emitted in byte order (status
|
||||
payloads, then titles in sequence, then bell — the renderer drain's order).
|
||||
2. Deliberately **not** synchronized with `pty:data`: side effects must keep
|
||||
advancing while renderer delivery is ACK-gated (contract invariant 1). A
|
||||
completion title may reach the store before the visible xterm paints the
|
||||
final output; that is acceptable — attention/title state is out-of-band UI
|
||||
state, and today's renderer drain already decouples by many batches under
|
||||
timer throttling.
|
||||
3. No attention replay: facts emitted while no renderer is subscribed are
|
||||
dropped. On transport attach/park-handoff the renderer pulls a title-only
|
||||
snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces
|
||||
the eager-buffer behavior where replay restores titles but is barred from
|
||||
bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The
|
||||
store handler ignores a replay title older (by `outputSequence`) than the
|
||||
last live title fact it applied.
|
||||
|
||||
## Renderer Store Handler (policy stays in the renderer)
|
||||
|
||||
Notification semantics, all preserved across the authority flip:
|
||||
|
||||
- BEL marks worktree+tab unread unconditionally — including the focused pane
|
||||
(`onBell`, `pty-connection.ts`); pane unread only behind
|
||||
`experimentalTerminalAttention`; keydown clears unread
|
||||
(`onTerminalKeyDown`, `pty-connection.ts`).
|
||||
- BEL's OS notification is delayed 250 ms and yields to a pending
|
||||
agent-task-complete (`scheduleTerminalBellNotification`,
|
||||
`pty-connection.ts`).
|
||||
- working→idle starts the Claude cache timer (null settings = not hydrated,
|
||||
treat enabled) and schedules completion with 250 ms grace + 1500 ms max
|
||||
wait + detail-wait store subscription
|
||||
(`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` /
|
||||
`AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`,
|
||||
`agent-task-complete-policy.ts`).
|
||||
- Completion unread is suppressed only for the exact visible foreground pane
|
||||
(`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread
|
||||
has no such check.
|
||||
- Dispatch-time liveness/staleness guards
|
||||
(`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's
|
||||
5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`,
|
||||
`src/main/ipc/notifications.ts`) remain the final gates.
|
||||
|
||||
These need live renderer store state (PTY/layout maps, pane visibility,
|
||||
settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer:
|
||||
the pane-independent per-paneKey handler module
|
||||
(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and
|
||||
subsumes both `pty-connection.ts`'s callbacks and the parked watcher's
|
||||
callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) —
|
||||
one policy path whether the tab is mounted, hidden, or parked. Main holds
|
||||
**no** notification timers; only the stale-title timer (parser state) lives
|
||||
in main.
|
||||
|
||||
## Synthetic Frame Reroute
|
||||
|
||||
`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`,
|
||||
`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`,
|
||||
so synthetic agent-title/BEL frames enter the per-PTY tracker directly —
|
||||
**not** `onPtyData`, so emulator state, tails, transcripts, and stats never
|
||||
see them. The decorative-frame visibility gating
|
||||
(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data`
|
||||
copy survives only in kill-switch-off mode, where renderer parsers still
|
||||
need the bytes. The visible xterm renders nothing from titles, but
|
||||
`pane.terminal.onTitleChange` feeds `registerPtyTitleSource`
|
||||
(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile
|
||||
parity); main prefers its own tracked title over renderer snapshot
|
||||
`lastTitle` in both serialize paths. Under main authority synthetic frames
|
||||
no longer produce phantom ACKs for bytes main never metered (`ackPtyData`,
|
||||
`pty-dispatcher.ts`).
|
||||
|
||||
## Migration Switch and Double-Fire Prevention
|
||||
|
||||
Authority is structural per PTY kind — the predicate is "bytes transit local
|
||||
main", exactly the `shouldOwnAgentStatusInRenderer` split
|
||||
(`pty-connection.ts`). One renderer-consulted kill switch
|
||||
(`settings.terminalMainSideEffectAuthority`, default on, mirroring
|
||||
`terminalHiddenViewParking`): when on, IPC transports and the parked watcher
|
||||
do not register byte parsers for local/SSH and the store handler consumes
|
||||
`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect`
|
||||
events are ignored. Main always parses and emits (its internal consumers need
|
||||
the tracker regardless); main consults the same setting only to keep the
|
||||
legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly
|
||||
one consumer per fact at any time — decided at transport/watcher creation, so
|
||||
no per-chunk race.
|
||||
|
||||
## Sidecar Consumers and Phase 4
|
||||
|
||||
Keep renderer byte access (input pacing / raw-output consumers, not side
|
||||
effects): `agent-paste-draft.ts` (DECSET 2004 readiness),
|
||||
`launch-agent-background-session.ts` (startup-injection pacing, onData
|
||||
passthrough), `automation-session-observer.ts` (onData passthrough), and
|
||||
`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for
|
||||
parked tabs while the delivery gate is off). Their duplicated local OSC 9999
|
||||
store writes are gated off under main authority (the `onAgentStatus`
|
||||
automation callbacks still fire; only the racing `setAgentStatus` store
|
||||
writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active
|
||||
`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a
|
||||
ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main
|
||||
authoritative, the parked watcher is purely fact-driven: byte parsing exists
|
||||
only in kill-switch-off mode, and the 2031 reply comes from the
|
||||
`2031-subscribe` fact when the gate is on (the byte responder sidecar only
|
||||
when it is off). The watcher file is deleted outright only when the kill
|
||||
switch retires — it returns as a byte parser only if remote-runtime tabs
|
||||
ever become parkable.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Every byte is side-effect-parsed exactly once, by exactly one authority,
|
||||
chosen structurally per PTY kind.
|
||||
2. Attention facts never replay: snapshot/eager/attach replays restore title
|
||||
state only.
|
||||
3. Notification policy (grace timers, yielding, suppression, dispatch guards)
|
||||
lives with the renderer store; main emits facts with ordering metadata.
|
||||
4. Side-effect facts keep flowing while renderer byte delivery is
|
||||
backpressured, parked, or stopped by the hidden-delivery gate.
|
||||
5. Synthetic agent frames feed the model tracker, never the emulator, tails,
|
||||
transcripts, or stats.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte
|
||||
fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning
|
||||
OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under
|
||||
fake timers, OSC 133;D, split PR URLs) run through the renderer
|
||||
`createPtyOutputProcessor` and the main tracker; assert identical ordered
|
||||
fact sequences.
|
||||
- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle
|
||||
parity, tui-idle waiter transitions, synthetic ingestion); store-handler
|
||||
tests reusing `parked-terminal-byte-watcher.test.ts` scenarios.
|
||||
- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring,
|
||||
`parked-terminal-byte-watcher.test.ts` (retires with the watcher);
|
||||
`pty-transport*.test.ts` stay (processor remains for remote + kill switch).
|
||||
- E2E gates that must stay green throughout: `terminal-attention.spec.ts`,
|
||||
`droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`,
|
||||
`terminal-parked-memory.spec.ts`; add main-authority bell/completion cases
|
||||
(parked tab, focused-pane suppression, kill switch off). SSH parity is
|
||||
exercised manually per the SSH test procedure before each slice ships.
|
||||
|
||||
## Cut-Offs (shipped as four stacked slices)
|
||||
|
||||
1. **Shared tracker in main.** Extract the processor core to shared, run the
|
||||
per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity
|
||||
tests. Main-internal consumers only; no IPC or renderer change.
|
||||
2. **Authority flip.** `pty:sideEffect` channel, renderer store handler,
|
||||
titles/bell/tracker authority to main for local+SSH behind the kill
|
||||
switch; parked watcher stops byte parsing for those kinds.
|
||||
3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`;
|
||||
OSC 133;D and PR-link facts; mobile `lastTitle` source preference.
|
||||
4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked
|
||||
watcher shrunk to fact-driven mode (deletion waits on kill-switch
|
||||
retirement), Phase 4 delivery-interest registration documented in the gate
|
||||
design.
|
||||
|
||||
## Open Items
|
||||
|
||||
- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic
|
||||
checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon
|
||||
`Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is
|
||||
derived from real PTY bytes only — synthetic hook title frames never reach
|
||||
the daemon process, so that field cannot carry hook-driven titles. Today no
|
||||
restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots
|
||||
surface only the ANSI payload), so there is nothing to fix. Main-side
|
||||
consumers of the renderer serializer's `lastTitle` (mobile snapshot reads
|
||||
and the headless hydration seed) prefer main's tracked title. If a future
|
||||
consumer starts reading checkpoint `lastTitle`, it must route through the
|
||||
same tracked-title preference.
|
||||
- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is
|
||||
removed, the parked watcher's byte-parser mode, the renderer transport
|
||||
parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all
|
||||
become dead code and the watcher byte path can be deleted outright.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Terminal Main-Owned State
|
||||
|
||||
This document covers the hidden-output recovery slice. The broader terminal
|
||||
model/view boundary is defined in
|
||||
[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md).
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden and background terminal panes cannot rely on renderer memory as the only
|
||||
@@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for
|
||||
local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated
|
||||
and can serialize it.
|
||||
|
||||
Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default
|
||||
on — see the contract's Architecture Status), main drops hidden renderer-bound
|
||||
bytes after model ingestion and emits an out-of-band restore marker, so a
|
||||
gated hidden pane accumulates no renderer backlog at all. The overflow path
|
||||
below is the fallback for kill-switch-off mode and for hidden PTYs with an
|
||||
active delivery-interest sidecar.
|
||||
|
||||
The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded:
|
||||
|
||||
1. The scheduler replaces the queued backlog with a small warning fallback.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.26",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
# Garble differential fuzz — divergence log
|
||||
|
||||
Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz
|
||||
(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is
|
||||
a case where restoring a hidden terminal from its main-side snapshot
|
||||
(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal)
|
||||
produces a screen that differs from an always-visible renderer terminal fed the
|
||||
same bytes. Any such diff is a user-visible garble on reveal.
|
||||
|
||||
## Method
|
||||
|
||||
- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane
|
||||
sizes, PTY-style random chunk splitting.
|
||||
- Differential: production `HeadlessEmulator` snapshot replayed into a fresh
|
||||
renderer-parity terminal, compared cell-by-cell (text, per-cell style,
|
||||
cursor, modes, scrollback) against an always-visible renderer-parity twin.
|
||||
- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's
|
||||
buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`,
|
||||
`vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified
|
||||
against `buildDefaultTerminalOptions` in
|
||||
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and
|
||||
`pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`,
|
||||
`drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored
|
||||
cell attributes, so their omission is not a source of false diffs.
|
||||
`windowsMode` is unset in both (matches renderer). Addon versions:
|
||||
`@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all
|
||||
`*-beta.287` (headless `6.1.0-beta.287`).
|
||||
- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug
|
||||
(predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is
|
||||
listed below.
|
||||
|
||||
## Inventory
|
||||
|
||||
| bug | found by | seeds | classification |
|
||||
| --- | --- | --- | --- |
|
||||
| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN |
|
||||
| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) |
|
||||
| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) |
|
||||
| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) |
|
||||
| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) |
|
||||
|
||||
Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and
|
||||
their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D
|
||||
carries position only (saved SGR/charset are not re-established — the synthetic
|
||||
ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate
|
||||
snapshot field written LAST by restorers because any later ESC (e.g. the
|
||||
post-replay reset) would abort the dangling sequence; its bytes are already
|
||||
counted by the snapshot seq, so tail-slice arithmetic is unchanged.
|
||||
|
||||
All five bug classes are reproduced by dedicated minimal `test.skip` repros so
|
||||
they cannot silently regress, AND each is tolerated + counted by its suite's
|
||||
corpus loop so deep mode surfaces only genuinely NEW divergences:
|
||||
|
||||
- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via
|
||||
`snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized
|
||||
snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a
|
||||
full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`.
|
||||
- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the
|
||||
Bug-C cursor cascade are kept out of the corpus by an append-only racing tail
|
||||
(no DECSC/cursor motion) and pinned only as standalone repros. Green at the
|
||||
default 200 and at `FUZZ_ITERATIONS=1000`.
|
||||
|
||||
Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts
|
||||
tripping on most seeds fails the suite instead of silently swallowing it.
|
||||
|
||||
Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored
|
||||
text mid-line") does not diverge on the current harness. It is a `savedCursor
|
||||
Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the
|
||||
emulator and the serializer here. It was most likely an earlier observation
|
||||
folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a
|
||||
transient during harness construction. No live divergence at 113.
|
||||
|
||||
---
|
||||
|
||||
## Bug A — SerializeAddon drops null cells at a soft-wrap boundary
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found
|
||||
and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz
|
||||
suite (V1 seed 31, V2 seed 157). Full mechanism documented in
|
||||
`bufferHasSerializeHostileWrappedRow` and the suite's headline comment.
|
||||
|
||||
- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell
|
||||
passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps
|
||||
at the right margin, overwriting the previous row's last cell and shifting the
|
||||
tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`.
|
||||
- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null
|
||||
takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so
|
||||
the ECH erase lands one cell right and the first filler `-` survives.
|
||||
|
||||
**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake,
|
||||
mobile subscribe replay) paints lost/shifted characters or stray `-` fillers
|
||||
when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite;
|
||||
unskip the repros when upstream fixes or a local serialize post-processor lands.
|
||||
|
||||
---
|
||||
|
||||
## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
|
||||
|
||||
**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops).
|
||||
|
||||
**Minimal repro (isolated, no fuzz corpus needed), cols=20:**
|
||||
|
||||
```
|
||||
live bytes: "\x1b[2mA\x1b[22m\x1b[1mB"
|
||||
SerializeAddon → : "\x1b[2mA\x1b[1;22mB"
|
||||
live cell B: bold=1 dim=0 (style flags 100000)
|
||||
restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST
|
||||
```
|
||||
|
||||
**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen
|
||||
from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in
|
||||
xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the
|
||||
emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is
|
||||
neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`.
|
||||
(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is
|
||||
specific to a dim-cell → bold-only-cell attribute transition.)
|
||||
|
||||
**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a
|
||||
bold status/spinner line (Claude Code, Codex). On the live screen the status
|
||||
line is bold; after a hide→reveal snapshot restore it renders normal-weight.
|
||||
The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored.
|
||||
|
||||
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
|
||||
`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`.
|
||||
Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`.
|
||||
|
||||
---
|
||||
|
||||
## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
|
||||
|
||||
**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK).
|
||||
|
||||
**Minimal repro (isolated, pure serializer replay), cols=10:**
|
||||
|
||||
```
|
||||
live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5)
|
||||
SerializeAddon → : "0123456789\x1b[2B\x1b[6D"
|
||||
live cursor: { x: 4, y: 2 }
|
||||
restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT
|
||||
```
|
||||
|
||||
Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"`
|
||||
and round-trips the cursor exactly, isolating the trigger to a full-width final
|
||||
content row.
|
||||
|
||||
**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in
|
||||
the *wrap-pending* state (cursor visually on the last column, logically "one
|
||||
past"). The serializer computes its final cursor-restore as relative
|
||||
`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed
|
||||
one column short, so the restored cursor lands at `x-1`. Reproduced with pure
|
||||
`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble
|
||||
or normalization involved**, confirming it is upstream, not Orca's snapshot
|
||||
path.
|
||||
|
||||
**Why it garbles a real pane:** the cursor is where the next keystroke echoes
|
||||
and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line
|
||||
reached the right edge (wide status lines, long prompts), the cursor sits one
|
||||
cell left of where the live pane had it — visible as a mispositioned prompt
|
||||
caret or spinner, and subsequent input can overwrite the wrong cell.
|
||||
|
||||
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
|
||||
`it.skip('restores the cursor exactly when the last content row fills the right margin')`.
|
||||
Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`.
|
||||
|
||||
---
|
||||
|
||||
## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary
|
||||
|
||||
**Classification: (a) real bug — a structural snapshot limitation.** New
|
||||
finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity
|
||||
fuzz.
|
||||
|
||||
**Minimal repro (cols=20):**
|
||||
|
||||
```
|
||||
hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2,
|
||||
move to r3c9, write CD)
|
||||
tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X)
|
||||
|
||||
live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 }
|
||||
reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 }
|
||||
^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2
|
||||
snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all)
|
||||
```
|
||||
|
||||
**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few
|
||||
rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` /
|
||||
`CSI u`) is runtime state that never appears in the serialized buffer, so it
|
||||
cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before
|
||||
the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or
|
||||
`\x1b[u`), the restore targets the fresh terminal's default saved position
|
||||
(home) instead of where the TUI saved it — the next writes land at the wrong
|
||||
cell and overwrite live content.
|
||||
|
||||
**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and
|
||||
status-line redraws (save cursor, jump to a corner to paint a clock/token
|
||||
counter, restore). If the save happens while the pane is hidden and the restore
|
||||
fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2
|
||||
seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and
|
||||
whose `\x1b8` fell in the racing tail after chunk-splitting).
|
||||
|
||||
**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor
|
||||
motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is
|
||||
tested in isolation from this and the other terminal-state-loss garbles. Bug D
|
||||
is instead pinned as a standalone repro,
|
||||
`hidden-reveal-reconciliation.fuzz.test.ts` →
|
||||
`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`.
|
||||
|
||||
**Fix (applied):** the snapshot epilogue re-establishes the register with
|
||||
`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`,
|
||||
reading the active buffer's core register via `readSavedCursorRegister`
|
||||
(alt screen yields its own register). Position-only: saved SGR/charset are not
|
||||
carried.
|
||||
|
||||
---
|
||||
|
||||
## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence
|
||||
|
||||
**Classification: (a) real bug — a structural snapshot limitation.** New
|
||||
finding, surfaced by the reveal-reconciliation fuzz (suite 2).
|
||||
|
||||
**Minimal repro (cols=20):**
|
||||
|
||||
```
|
||||
hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet)
|
||||
tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD)
|
||||
|
||||
live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic)
|
||||
reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character
|
||||
snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone)
|
||||
```
|
||||
|
||||
**Mechanism:** a PTY read (one delivery record) can split an escape sequence.
|
||||
If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the
|
||||
serialized SCREEN cannot carry the partial sequence (it lives in the parser
|
||||
state machine, not the buffer). The racing tail supplies the sequence's
|
||||
remaining bytes, but with the prefix gone the terminal parses them as literal
|
||||
text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`.
|
||||
|
||||
**Why it garbles a real pane:** any TUI whose output is heavy with escape
|
||||
sequences (all of them) can have a read boundary fall mid-escape; if a reveal
|
||||
lands in that window the continuation renders as stray literal bytes (a rogue
|
||||
`m`, `H`, digits) injected into the visible text.
|
||||
|
||||
**Reachability:** requires the reveal/snapshot to fire in the gap between the two
|
||||
halves of a split escape. `main` writes each PTY read to the emulator and
|
||||
records it as one delivery unit (`session.ts emitSubprocessOutput`), and the
|
||||
snapshot is taken synchronously at a drain — so the window is a single delivered
|
||||
record that ended mid-escape. Narrow but real.
|
||||
|
||||
**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends
|
||||
mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the
|
||||
serialize wrap bug — it fired on ~24% of the corpus, confirming the class is
|
||||
common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` →
|
||||
`it.skip('completes an escape sequence split across the hide/reveal boundary')`.
|
||||
|
||||
**Fix (applied):** the emulator tracks the unparsed trailing partial escape at
|
||||
ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse
|
||||
mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers
|
||||
write it LAST, after their post-replay resets, so the racing tail's
|
||||
continuation completes it exactly as live. Snapshot seq already counted those
|
||||
ingested bytes, so reconcile slicing is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Known-legitimate normalization (NOT bugs)
|
||||
|
||||
- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link
|
||||
cells underlined; SerializeAddon never re-emits OSC 8. Production restores the
|
||||
link ranges out-of-band via `snapshot.oscLinks`
|
||||
(`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the
|
||||
underline by design. Pinned by the passing
|
||||
`it('drops OSC 8 underline from byte replay but preserves the range …')`.
|
||||
- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette
|
||||
indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored
|
||||
cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the
|
||||
same 16 theme slots — no visual difference. Canonicalized by
|
||||
`canonicalColorMode` in the parity fixture.
|
||||
|
||||
---
|
||||
|
||||
## Corpus vs deep mode
|
||||
|
||||
- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default
|
||||
`FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A,
|
||||
B, and C are each tolerated + counted by a predicate, so the corpus fails only
|
||||
on a genuinely new divergence.
|
||||
- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default
|
||||
`FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail
|
||||
is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`).
|
||||
- Combined default runtime is ~19s (well under the 60s gate).
|
||||
- `FUZZ_SEED=<n>`: re-run exactly one seed for a repro (both suites).
|
||||
@@ -0,0 +1,532 @@
|
||||
# orca-performance Branch Guide
|
||||
|
||||
Agent-facing map of every optimization on this branch: what it does, why it exists,
|
||||
where it lives, and the invariants you must not break when adding to it. The
|
||||
chronological evidence trail (benchmarks, retractions, A/B protocols) is in
|
||||
`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view.
|
||||
|
||||
**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load
|
||||
(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s).
|
||||
As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput
|
||||
11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms
|
||||
(10× Terminal.app).
|
||||
|
||||
## The pipeline
|
||||
|
||||
```
|
||||
shell → pty → daemon (persistence, headless model) → unix socket
|
||||
→ main (ipc/pty.ts: batching, delivery gate, flow control, snapshots)
|
||||
→ IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm)
|
||||
```
|
||||
|
||||
Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer
|
||||
MessagePort). The daemon owns sessions so they survive app restarts; it also runs
|
||||
a headless xterm emulator per pty — the _model_ — which is the source of truth
|
||||
for screen contents. The renderer terminal is a _view_ that can be discarded and
|
||||
rebuilt from model snapshots.
|
||||
|
||||
## Optimization inventory
|
||||
|
||||
### 1. Renderer parse-path fixes (the original 16× on agent TUIs)
|
||||
|
||||
- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain
|
||||
cadence follows xterm's actual parse completion instead of fixed timers, so the
|
||||
queue never outruns the parser.
|
||||
- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only
|
||||
re-process a bounded window instead of the full retained tail. Guarded by
|
||||
differential fuzz `retained-tail-redraw-window.equivalence.test.ts`.
|
||||
- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent
|
||||
wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at
|
||||
50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's
|
||||
per-chunk cost.
|
||||
|
||||
### 2. term-speed-2 chain (model/view contract — the architecture)
|
||||
|
||||
Revived from ~38 never-merged branches; kill-switched, default ON. Docs:
|
||||
`docs/reference/terminal-model-view-contract.md`.
|
||||
|
||||
- **Hidden view parking**: hidden tabs tear down their xterm view entirely
|
||||
(memory: parked panes cost ~0).
|
||||
- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are
|
||||
dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a
|
||||
model snapshot + live chunks after the snapshot's seq.
|
||||
- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd)
|
||||
from the model so parked panes stay live in the UI without a view.
|
||||
- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC
|
||||
colors) deterministically from the model for hidden panes.
|
||||
- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal
|
||||
reconciliation drops duplicates already covered by the snapshot baseline.
|
||||
|
||||
### 3. Batching & scheduling cadence
|
||||
|
||||
- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts`
|
||||
(`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`).
|
||||
At 9% utilization there is no queue — latency was literally the sum of fixed
|
||||
batch windows. This one change took dev DSR-load 19→8ms.
|
||||
- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`):
|
||||
Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks
|
||||
without the clamp, preserving cooperative yield (input/paint still serviced).
|
||||
Vitest keeps the timer path (fake timers can't advance channel posts).
|
||||
- **Input write coalescing** (from main, #7205): renderer input writes coalesce
|
||||
instead of queuing macrotask-per-keystroke.
|
||||
|
||||
### 4. Backpressure (the correctness spine — read before touching delivery)
|
||||
|
||||
Three cooperating layers, innermost first:
|
||||
|
||||
- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler
|
||||
`ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the
|
||||
chunk is legitimately discarded), not when IPC delivered it.
|
||||
**INVARIANT: every delivered chunk credits exactly once — parsed or
|
||||
discarded.** Every scheduler/pty-connection discard path (backlog replacement,
|
||||
disposed terminal, reconcile drop, split remainders) must fire the credit.
|
||||
- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`,
|
||||
`applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals
|
||||
(TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a
|
||||
fully-gated pty triggers a resync probe instead of a timeout reset. The only
|
||||
timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight
|
||||
gate + 2MB pendingData cap sit on top.
|
||||
- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`,
|
||||
`pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed
|
||||
wedge where every main→renderer PUSH channel dies while invoke stays alive
|
||||
(v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden
|
||||
resync probe can never reach. The 15s heartbeat costs one Map upsert per
|
||||
received chunk and does no IPC while output flows; mutation stays
|
||||
verified-state-only (the timer decides when to REPORT; the write-off derives
|
||||
entirely from the renderer's cumulative received totals, never wall-clock,
|
||||
and a received-but-unparsed window is never written off). Heal = re-attach
|
||||
push listeners + pull restore markers through the modelRestoreNeeded router.
|
||||
E2e blackhole harness: `__terminalDeliveryWatchdog`,
|
||||
`terminal-push-delivery-loss-recovery.spec.ts`.
|
||||
- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts`
|
||||
and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts):
|
||||
recovers the field-confirmed wedge where macOS occlusion tracking pins
|
||||
`document.visibilityState` at `'hidden'` after display sleep and never fires
|
||||
another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate
|
||||
dropped across 2 pane-level-visible ptys, transport healthy). Real user
|
||||
input (keydown/pointerdown/window focus) while the document claims hidden is
|
||||
a physical contradiction — it latches an override, runs each pane's existing
|
||||
visibilitychange resync (gate unhide + hidden-output restore), and a genuine
|
||||
visibilitychange hands authority back. No timers; recovery is purely
|
||||
event-proven, and the failure bias is safe (a wrong override only restores
|
||||
pre-gate delivery cost, never drops bytes). Hot-path cost: zero when
|
||||
visible (same single comparison); one property read per user-interaction
|
||||
event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the
|
||||
freeze repro and the keystroke recovery, plus the
|
||||
`hiddenDeliveryGatedVisiblePtyCount` field discriminator).
|
||||
- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed
|
||||
`await window.__orcaTerminalFreezeReport()`): a single DevTools command that
|
||||
returns renderer state (visibilityState + stale override, pty:data listener
|
||||
count, watchdog totals), main's snapshot with a per-pty delivery table
|
||||
(sent/acked/pending, hidden vs visible-set membership, last send/ACK ages,
|
||||
window focus flags, power suspend/resume ages, app version), and bounded
|
||||
breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared
|
||||
ring: 100 entries, same-kind coalescing) recording gate marks, visibility
|
||||
trust changes, watchdog stalls/heals, restore markers, heal write-offs,
|
||||
renderer lifecycle resets. Pty ids are redacted to their `@@` suffix —
|
||||
daemon session ids embed worktree paths. Recording happens only on rare
|
||||
transitions; the table/report is built only when read. This exists so a
|
||||
field freeze report never needs a follow-up ask.
|
||||
- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit
|
||||
guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that
|
||||
keep a split pane's death near the hidden/park boundary from stranding state
|
||||
(field incident: a closed setup-split leaf persisted in `root` with no
|
||||
binding and remounted as a permanently blank pane, unreachable by
|
||||
dead-session reconcile — it skips ptyId-null panes by design).
|
||||
(1) The "keep a fresh split whose newborn PTY died" branch is **gated on
|
||||
`isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output"
|
||||
proves nothing there; a hidden newborn death must `closePane`, or the kept
|
||||
pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked**
|
||||
reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a
|
||||
mounted TerminalPane), so the sidecar itself collapses the dead leaf out of
|
||||
the stored layout via `detachTerminalLayoutLeaf` — a stale binding left
|
||||
behind reattaches on reveal and the daemon re-creates the exited session id
|
||||
as a fresh shell (silent pane resurrection). E2e:
|
||||
`terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every
|
||||
lifecycle phase and asserts leaves(root) == bindings == live panes.
|
||||
- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause /
|
||||
32KB resume watermarks, keyed off **pendingData only** — never renderer
|
||||
counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's
|
||||
buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous
|
||||
(drops impossible); for daemon ptys the pause notify has ~20-30ms socket
|
||||
latency, so wire-speed bursts can still cross the 2MB cap (known follow-up:
|
||||
daemon-side self-pacing watermark).
|
||||
- **Shallow stream-socket write gate + per-session fairness**
|
||||
(`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices /
|
||||
4KB small-session bypass / 32MB write-through valve; kill switch
|
||||
`ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for
|
||||
every session — bytes already written can never be overtaken, so a deep
|
||||
user-space buffer buries a visible pane's echo behind other panes' bulk
|
||||
(measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at
|
||||
the gate and hold in the batcher, where the interactive flushSession path
|
||||
and the deterministic small-session bypass still jump them; socket `drain`
|
||||
refills. This layer alone bounds echo latency by the shallow depth.
|
||||
- **Background keep-tail stream thinning + daemon fact authority**
|
||||
(`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail,
|
||||
`daemon-background-transient-facts.ts`; kill switch
|
||||
`ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from
|
||||
pendingData flow control (main drops their bytes after ingestion), which
|
||||
let N background agents run unbounded ahead of main. Main mirrors the
|
||||
hidden-delivery gate to the daemon via the wire-tolerated
|
||||
`setSessionBackground` notification (introduced in v19; authoritative
|
||||
thinning requires v20 snapshots, so preserved v19 sessions are explicitly
|
||||
unthinned; older daemons swallow it) — but a live remote view subscriber
|
||||
(mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`).
|
||||
Backgrounded sessions' queued output is keep-tail dropped (oldest bytes
|
||||
replaced by an in-order `dataGap` event; reply-eliciting query bytes are
|
||||
salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused,
|
||||
so reveal stays instant with zero catch-up. Un-background neither discards
|
||||
nor force-flushes the queued tail — restore paths read MAIN's model
|
||||
(hidden-output recovery buffer), so a discarded tail loses a finished
|
||||
program's last output forever (caught by the ACK-backpressure e2e), and a
|
||||
16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own
|
||||
bytes; the ordered drain loop delivers it within the budget below. Two
|
||||
aggregate bounds make that budget real: (1) a GLOBAL background keep
|
||||
budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more
|
||||
backgrounded sessions hold queued data, and tighten retroactively when the
|
||||
count grows — without this, N sub-cap sessions queue N×cap and a worktree
|
||||
switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden
|
||||
restore vs the 1.5s budget, probe-verified drain overlap); (2) a
|
||||
kernel-flush refill sentinel: a held flush pass arms one ~90B empty data
|
||||
event whose write callback re-flushes when the kernel accepts the
|
||||
in-flight bytes — without it, held bulk advances one gate-depth per
|
||||
'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy
|
||||
daemon). NOTE: an empty `socket.write('')`'s callback fires immediately
|
||||
even with megabytes buffered (verified) — the sentinel must be a real
|
||||
protocol no-op line. Notifications are
|
||||
structurally lossless: while backgrounded, the DAEMON runs the same shared
|
||||
scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133
|
||||
command-finished / pr-link / DECSET 2031) over every byte BEFORE drop
|
||||
decisions and relays facts as in-order `transientFact` events; ordered
|
||||
`sessionBackgroundMarker` events hand scan authority back and forth
|
||||
(main suppresses just those four scanners in between), and the emulator's
|
||||
`partialEscapeTailAnsi` seeds each side's fresh scanner carry so a
|
||||
sequence split across the handoff neither phantom-fires nor goes missing.
|
||||
Titles/agent-status stay main-side (they converge from the kept tail and
|
||||
fuse with synthetic spinner frames). On `dataGap`, main resets its
|
||||
cross-chunk parse carries, drops the mobile headless mirror (rebuilds from
|
||||
tail/seeds), and sends the model-restore-needed marker so any
|
||||
renderer-side buffer heals from the snapshot. Visible ptys are never
|
||||
touched by this layer. Backlog observability:
|
||||
`ORCA_DAEMON_STREAM_BACKLOG_FILE=<path>` JSONL
|
||||
(`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`,
|
||||
`setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`).
|
||||
Causation A/B (`bench:multi-workspace-typing`): realistic steady rates
|
||||
(8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine
|
||||
do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50
|
||||
29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms.
|
||||
|
||||
### 5. Flood resilience (why bulk output can't wedge or lie anymore)
|
||||
|
||||
- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop
|
||||
abandons immediately when a foreground pane's live-chunk queue overflows
|
||||
(3-iteration hard cap), and a 2s flood-suppression window stops main's own
|
||||
backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming
|
||||
restore — bytes write through, ONE deferred repaint heals after the flood.
|
||||
This killed a positive feedback loop (restore starves ACKs → main drops →
|
||||
drop re-arms restore) that caused multi-second renderer stalls.
|
||||
- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal
|
||||
queries are extracted (`terminal-reply-query-extraction.ts`) and answered by
|
||||
_synthesizing replies on the input path_ (CPR from live buffer, DA1 canned,
|
||||
OSC via direct responder) — probes and TUIs never hang on a dropped reply.
|
||||
- Drops are downstream of the model: the daemon ingests every byte, so the
|
||||
post-flood repaint restores complete, correct content.
|
||||
|
||||
### 6. Wake/sleep recovery
|
||||
|
||||
- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes
|
||||
WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed).
|
||||
- Cumulative ACKs make the historical "lost ACKs across suspend pin the global
|
||||
window forever" wedge (BMW user bug) structurally impossible.
|
||||
|
||||
### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned)
|
||||
|
||||
Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs
|
||||
found by differential fuzzing; four fixed, one tolerated:
|
||||
|
||||
- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted
|
||||
`1;22` (22 clears the bold 1 just set). Patched via pnpm patch
|
||||
(`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the
|
||||
bold/dim group (+2 sibling bare-22 defects).
|
||||
- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely:
|
||||
`serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`)
|
||||
appends absolute CUP from the source terminal's authoritative cursor
|
||||
(skipped when wrap-pending, where CUP would corrupt).
|
||||
- **D: DECSC saved-cursor register not serialized** — snapshot appends
|
||||
`CUP(saved) + ESC 7 + CUP(actual)` when a register exists.
|
||||
- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) —
|
||||
`terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the
|
||||
unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written
|
||||
LAST on restore so continuation bytes complete the sequence. Seq accounting
|
||||
unchanged (the tail is a suffix of bytes ≤ snapshot seq).
|
||||
- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by
|
||||
`bufferHasSerializeHostileWrappedRow`, the only remaining tolerance.
|
||||
|
||||
## Correctness infrastructure (run these before merging delivery/restore changes)
|
||||
|
||||
- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs
|
||||
reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep,
|
||||
`FUZZ_SEED=n` to replay.
|
||||
- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random
|
||||
hide/reveal boundaries × snapshot seq × racing chunks must equal an
|
||||
always-visible reference.
|
||||
- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped
|
||||
= regression alarms).
|
||||
- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test —
|
||||
byte-identical vs control), `terminal-parked-memory`,
|
||||
`terminal-sleep-wake-restore`.
|
||||
- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests
|
||||
(pane-manager / terminal-pane suites).
|
||||
|
||||
## Benchmarking protocol (hard-won rules)
|
||||
|
||||
- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under
|
||||
1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the
|
||||
terminal under test.
|
||||
- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12
|
||||
--rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label <build>`
|
||||
— real keystrokes (CDP) into a visible pane while N hidden-worktree panes
|
||||
replay paced agent-TUI streams through real daemon ptys; decomposes each key
|
||||
into input-half (keydown→pty, sidecar timestamps) and echo-half
|
||||
(pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s:
|
||||
p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer
|
||||
timer drift staying ~15ms while echo-half grows means the backlog is
|
||||
upstream of the renderer (daemon socket / main ingest).
|
||||
- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev
|
||||
benches used 3MB and never tripped the cap.
|
||||
- Load-controlled A/B only: alternate builds within one session; dev carries ~2×
|
||||
day-to-day variance. Never conclude from runs while agents/builds hammer the
|
||||
machine (two false convictions came from this).
|
||||
- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang).
|
||||
- Packaged builds are truth; dev has ~2× overhead.
|
||||
|
||||
## Release mechanics
|
||||
|
||||
- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance
|
||||
version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above
|
||||
its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter
|
||||
(`release-rc-history.mjs`), telemetry identity classifier, and build guard are
|
||||
all suffix-aware — a suffixed rc classifies as `rc`.
|
||||
- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release
|
||||
(PR #7278; merged here) — perf-line users self-update after one manual install.
|
||||
|
||||
## Syncing with main: MERGE, never rebase
|
||||
|
||||
`orca-performance` is a long-lived, shared, continuously-pushed integration
|
||||
branch — RCs are cut from it and agents branch off it. **Always
|
||||
`git merge origin/main`; never rebase** (rebasing rewrites pushed history and
|
||||
strands every RC tag, fix branch, and worktree based on the old commits).
|
||||
Conflict pattern, established over ~6 syncs:
|
||||
|
||||
1. **Our structure wins; main's semantics graft in.** This branch deliberately
|
||||
restructures terminal code (shared scanners, single-policy handlers,
|
||||
model/view split). When main adds a feature inside code we've restructured,
|
||||
keep our shape and port their new behavior into it. Example: main inlined an
|
||||
OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared
|
||||
`createOsc133CommandFinishedScanner` (main's side-effect tracker must parse
|
||||
byte-identically) and added 133;C support to the shared scanner instead.
|
||||
2. Preserve the invariants in **Guardrails** below through every resolution —
|
||||
especially chunk-credit, `pendingData`-keyed flow control, and the
|
||||
single `handleCommandFinished` policy (byte path AND sideEffect-fact path
|
||||
route through it).
|
||||
3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon
|
||||
suites, and both fuzz suites. Commit the merge with a message stating what
|
||||
was kept from each side; push. If the push races a moved remote, merge the
|
||||
remote tip — never `pull --rebase` a merge.
|
||||
4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench
|
||||
before the next RC cut.
|
||||
|
||||
## Known limits / next levers (in rough priority order)
|
||||
|
||||
1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at
|
||||
wire speed before `pausePty` bites. Fix: daemon enforces its own watermark
|
||||
locally (VS Code does this server-side for remotes).
|
||||
2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining
|
||||
drain cadence dominate the 13.3ms prod p50.
|
||||
3. utilityProcess router endgame: take main off the per-byte hot path
|
||||
(VS Code's ptyHost→renderer MessagePort shape).
|
||||
4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up).
|
||||
5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control →
|
||||
term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK)
|
||||
is open against main separately.
|
||||
|
||||
## Guardrails for future agents
|
||||
|
||||
- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path
|
||||
that receives, defers, drops, or splits pty data in the renderer, prove it
|
||||
credits exactly once. The credit-invariant unit tests are the gate.
|
||||
- Flow control keys off `pendingData` only. Do not couple it to renderer
|
||||
counters; the two layers compose because they are independent.
|
||||
- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes
|
||||
≤ its seq (Bug E made this true; don't regress it). Chunks after restore are
|
||||
reconciled by seq — off-by-N re-triggers duplicate-drop garble.
|
||||
- Hidden panes must receive nothing (delivery gate) but side-effects and query
|
||||
replies must stay live via the model. If you add a new query type, wire it
|
||||
through model authority AND the drop-path synthesis.
|
||||
- Never add timeout-based recovery that mutates counters (user requirement —
|
||||
design decision from #7260). Deterministic resync or nothing; hygiene timers
|
||||
may only log.
|
||||
- Any change on the delivery/restore path: run both fuzz suites, the roundtrip
|
||||
tests, the chain e2e trio, AND a 10MB bench before calling it done.
|
||||
|
||||
## Audit 1
|
||||
|
||||
Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main →
|
||||
renderer terminal path, with particular attention to hidden delivery, parking,
|
||||
stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach,
|
||||
mobile/remote composition, SSH routing, and teardown. The audit treated model
|
||||
state and user scrollback as correctness requirements, not expendable memory.
|
||||
|
||||
### Findings and fixes
|
||||
|
||||
1. **Hidden-gate handoff owners could undo one another.** A parked watcher and
|
||||
an unmounting/remounting pane shared one boolean hidden mark; likewise a
|
||||
retiring pane could report `visible=false` after its replacement had already
|
||||
reported `visible=true`. Hidden claims are now reference-counted and
|
||||
visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager
|
||||
pre-mount buffers no longer hold raw-byte delivery interest: they are not a
|
||||
side-effect consumer, and model-backed hidden output can restore from a
|
||||
snapshot. This removes the ownership and eager-interest races formerly
|
||||
listed in Known limits item 6 without weakening parked side effects. The
|
||||
remaining transient-visibility concern was checked separately: bind and
|
||||
reconnect reports read `TerminalPane`'s synchronously refreshed
|
||||
`isVisible && isWorktreeActive` ref, the global effect uses the same
|
||||
expression, and owner counting prevents a retiring pane from overriding its
|
||||
replacement. No hidden-worktree `visible=true` report site remains.
|
||||
|
||||
2. **A natural/synthetic daemon exit could overtake final output.** When a
|
||||
shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event
|
||||
directly. The final bytes could therefore arrive after `exit`. Exit is now an
|
||||
ordered control event in `DaemonStreamDataBatcher`; both natural and
|
||||
synthetic exits flush through the same FIFO. A deep-socket regression test
|
||||
pins final-data-before-exit ordering.
|
||||
|
||||
3. **Keep-tail thinning could permanently reduce scrollback to the retained
|
||||
tail.** On `dataGap`, main discarded its headless model, then rebuilt it from
|
||||
later tail bytes even though the daemon still owned the complete model. Live
|
||||
daemon snapshots now carry `outputSequence`; the provider exposes an
|
||||
authoritative `getBufferSnapshot`, accepts the requested scrollback depth,
|
||||
and main requires that provider snapshot after a gap. Reconciliation starts
|
||||
in the pre-snapshot absolute sequence domain and runtime sequence accounting
|
||||
advances across dropped bytes. The daemon remains the source of complete
|
||||
scrollback instead of making a transport optimization destructive. If that
|
||||
authoritative RPC is temporarily unavailable, main now returns no snapshot
|
||||
and lets the renderer retry; it never paints main's known-incomplete tail as
|
||||
a full recovery.
|
||||
|
||||
4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA
|
||||
bytes salvaged from a dropped region are copies of bytes already counted by
|
||||
the daemon, not new output. Stream events now distinguish delivered text
|
||||
from `sequenceChars`; salvaged query data advances by zero while the gap
|
||||
advances by the original characters. Main can still parse/deliver the query
|
||||
copy without shifting every later snapshot baseline.
|
||||
|
||||
5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK
|
||||
credit fired when bytes entered `terminal.write`, before xterm's callback.
|
||||
Split scheduler chunks also attached `onParsed` to the first slice. ACK
|
||||
credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the
|
||||
final xterm parse callback, and is released exactly once on throw, discard,
|
||||
or terminal disposal. Submitted-but-unparsed credit is retained until parse
|
||||
or disposal, so main's flow-control window measures parser work rather than
|
||||
renderer submission.
|
||||
|
||||
6. **Hidden restore ignored configured scrollback.** The renderer always asked
|
||||
for 5,000 rows, so users configured for 10k–50k silently lost older history
|
||||
on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback
|
||||
option and clamps it through the shared 0–50,000 policy
|
||||
(`terminal-hidden-restore-scrollback.ts`).
|
||||
|
||||
7. **Active alternate-screen snapshots discarded the normal shell buffer.**
|
||||
SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old
|
||||
normalization sliced away everything before the last `?1049h`. A restored
|
||||
TUI looked correct until it exited alternate mode, then returned to empty
|
||||
history. Snapshots now carry the normal buffer separately in
|
||||
`scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose
|
||||
both buffers; an already-alt renderer exits alt, clears/rebuilds the normal
|
||||
buffer, then re-enters and rebuilds alt. History replay also composes both
|
||||
buffers, including legacy empty-field compatibility.
|
||||
|
||||
Deep fuzz then found a second two-buffer issue: normal-buffer serialization
|
||||
can leave its SGR pen active while the separately serialized alternate body
|
||||
assumes default SGR. The rehydrate boundary now emits `SGR 0` before
|
||||
`?1049h`, preventing a shell color from tinting restored TUI cells. The
|
||||
regression proves the TUI is visible immediately and `?1049l` returns to the
|
||||
original shell history.
|
||||
|
||||
8. **Daemon provider wrappers forwarded only part of the recovery contract.**
|
||||
A preserved current/legacy daemon could emit `dataGap` through a provider
|
||||
wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while
|
||||
the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap
|
||||
events, snapshots, and explicit `sequenceChars`. Both wrappers now route the
|
||||
complete contract to the provider that owns the session, including requested
|
||||
50,000-row recovery and zero-advance query-salvage events.
|
||||
|
||||
9. **Sequence-safe recovery was added without advancing the daemon protocol.**
|
||||
An already-running v19 daemon could accept background-thinning hints but
|
||||
could not return the new `outputSequence`, making any resulting gap
|
||||
impossible to reconcile safely. The authoritative snapshot contract is now
|
||||
protocol v20. Preserved v19 sessions remain live but are explicitly marked
|
||||
unthinned; their stale background hint is cleared on the ordered control
|
||||
socket before `createOrAttach`, while fresh v20 sessions retain keep-tail
|
||||
performance and full-model recovery.
|
||||
|
||||
### Static audit conclusions
|
||||
|
||||
- Model query authority still captures ownership synchronously at ingestion;
|
||||
seed/hydration/snapshot writes remain reply-silent, remote view subscribers
|
||||
retain view authority, and replies use the provider input path (including
|
||||
daemon shell-ready queuing and SSH routing).
|
||||
- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared
|
||||
by the centralized PTY teardown path. Parked watcher timers, byte sidecars,
|
||||
fact consumers, exit subscriptions, hidden claims, and runtime-title slots
|
||||
dispose on reveal/exit/worktree shutdown.
|
||||
- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden
|
||||
panes still have a main-owned headless model, so mounted hidden-gate restore
|
||||
is valid; live remote viewers veto daemon background thinning. The new
|
||||
two-buffer payload is recomposed before mobile/remote snapshot frames.
|
||||
- Wake recovery keeps its focus/visibility/system-resume listener symmetry and
|
||||
cancels its settled animation frame on cleanup. No timeout was added that
|
||||
mutates ACK or delivery counters.
|
||||
- The one documented upstream SerializeAddon null-cell/wrapped-row defect
|
||||
remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row
|
||||
predicate as fidelity fuzz, rather than misclassifying that known serializer
|
||||
defect as sequence-reconciliation loss.
|
||||
|
||||
### Validation evidence
|
||||
|
||||
- Focused ownership/connection/dispatcher tests: 422 passed.
|
||||
- Daemon server/batcher/order tests passed, including deep queued-socket exit.
|
||||
- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale
|
||||
mocks were updated to assert the new explicit `sequenceChars` argument; the
|
||||
production behavior was already correct.
|
||||
- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed.
|
||||
- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed.
|
||||
- Post-protocol completion sweep: 1,106 affected main/daemon tests passed;
|
||||
484 renderer/restore tests passed with 2 expected skips.
|
||||
- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`.
|
||||
- Required hidden-view parking, parked-memory, and sleep/wake E2E trio:
|
||||
7 passed on a fresh v20 Electron build, including byte-identical output
|
||||
across 25 park/reveal cycles.
|
||||
- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s).
|
||||
- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It
|
||||
reproducibly found the SGR boundary bug at seed 16 and the known upstream
|
||||
wrapped-null-cell case at seed 1221 before the final green run.
|
||||
- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and
|
||||
remote incomplete-escape regression suites passed.
|
||||
- Fullscreen real-app headful flow: a real shell wrote normal history, entered
|
||||
a TUI while its worktree was hidden, restored on reveal, then exited with
|
||||
`?1049l` back to the original history. The BrowserWindow was fullscreen;
|
||||
no click/focus occurred before evidence; the restored frame settled for
|
||||
1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63;
|
||||
`fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap
|
||||
11px (the scrollbar/remainder, with grid and proposed dimensions equal).
|
||||
Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and
|
||||
`.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`.
|
||||
- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s
|
||||
(986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms,
|
||||
DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39,
|
||||
reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result:
|
||||
`tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`.
|
||||
- `pnpm typecheck`, oxlint on every touched TypeScript file,
|
||||
`pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production
|
||||
build all passed. No max-lines bypass was added.
|
||||
@@ -0,0 +1,583 @@
|
||||
# Terminal Performance Initiative
|
||||
|
||||
Working plan for the `orca-performance` branch. Goal: make Orca's terminal as
|
||||
performant as the architecture allows, with every claim backed by a number.
|
||||
Started 2026-07-02.
|
||||
|
||||
## Why (user-reported, from the team meeting)
|
||||
|
||||
1. Typing in the terminal is sometimes laggy — occasionally seconds of delay.
|
||||
2. Users say the terminal is slower than iTerm (unclear if typing or scrolling).
|
||||
3. Scrolling in Claude Code / OpenCode is slow.
|
||||
4. Idle memory is high (1–2 GB).
|
||||
5. Battery usage is high.
|
||||
|
||||
Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling
|
||||
latency down significantly; lower memory with 0–1 agents.
|
||||
|
||||
## Ground truth (verified against source, 2026-07-02)
|
||||
|
||||
Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified
|
||||
file:line claims) — see the archived digest and the "xterm.js vs Ghostty"
|
||||
deep-dive. The Orca-specific findings below were re-verified against this
|
||||
repo's code:
|
||||
|
||||
- **Electron main sits on every terminal byte's path** (daemon → main →
|
||||
renderer). VS Code ships the same xterm.js but bypasses main entirely: its
|
||||
ptyHost is a UtilityProcess with a direct MessagePort to each renderer.
|
||||
- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in
|
||||
both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery
|
||||
is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can
|
||||
grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k
|
||||
unacked chars (kernel backpressure blocks the shell).
|
||||
- Renderer terminals share one thread with the entire React app; xterm.js
|
||||
parses in 12 ms slices at a documented 5–35 MB/s ceiling.
|
||||
- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`),
|
||||
5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines)
|
||||
reflow on column resize.
|
||||
- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms
|
||||
(same-library reference), native class 5–10 ms. Realistic target: beat
|
||||
VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99
|
||||
dominates perception).
|
||||
|
||||
## Current state
|
||||
|
||||
Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`):
|
||||
|
||||
1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig
|
||||
(see Benchmark protocol below).
|
||||
2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards,
|
||||
probe-certified replay release) + #7139 (cooperative drain: paced backlog
|
||||
draining keeps typing responsive under floods). Post-merge on this base:
|
||||
`pnpm typecheck` clean, 626 targeted tests green (scheduler, guards,
|
||||
pty/pty-connection/pty-transport suites). #7153 itself is a disposable
|
||||
testing PR; #7139 and #7150 land separately on main.
|
||||
|
||||
## Workstreams
|
||||
|
||||
### 1. Baseline benchmarks (now; human-in-terminal required)
|
||||
|
||||
Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty,
|
||||
Terminal.app, VS Code (T3Code if available):
|
||||
|
||||
```
|
||||
node tools/benchmarks/terminal-pipeline-bench.mjs --label <machine>-<date>
|
||||
node tools/benchmarks/terminal-pipeline-bench.mjs report
|
||||
```
|
||||
|
||||
These numbers answer "are we actually slower than iTerm, and where," and are
|
||||
the before/after for everything below.
|
||||
|
||||
### 2. Validate #7153 on orca-performance (this week, extended testing)
|
||||
|
||||
Watch for: typing responsiveness under agent floods, bounded memory,
|
||||
skip-notice + snapshot repaint on overflow, no permanent input loss. When
|
||||
validated, land #7139 and #7150 as separate PRs on main.
|
||||
|
||||
### 3. Revive term-speed-2 (the headline structural work)
|
||||
|
||||
History: nwparker's ~38-branch chain (+20k lines) implementing the terminal
|
||||
model/view contract — hidden view parking, hidden delivery gate, side-effect
|
||||
authority in main, model query authority, skip-grammar deletion — all
|
||||
kill-switched, documented in
|
||||
`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`.
|
||||
It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was
|
||||
never rejected and never reached main. Directly targets complaints 3–5
|
||||
(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases).
|
||||
|
||||
Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34
|
||||
conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16),
|
||||
`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5).
|
||||
`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the
|
||||
chain touch different layers; runtime interaction (drain pacing × hidden
|
||||
gate) still needs deliberate testing.
|
||||
|
||||
Execution: dedicated focused session; resolve on `revive/term-speed-2` off
|
||||
orca-performance; keep both sides' kill switches; validate with typecheck +
|
||||
the contract tests listed in the model-view-contract doc + #7153's suites;
|
||||
merge back to orca-performance for extended testing. Estimated ~1 day of
|
||||
careful resolution + validation.
|
||||
|
||||
### 4. Remaining stall-bug fixes (parallel, independently shippable)
|
||||
|
||||
The "seconds of delay" class = discrete thread-blocking events, not
|
||||
steady-state latency:
|
||||
|
||||
- PR #7105 (open): skip synchronous cold-restore replay for live daemon
|
||||
sessions in doSpawn.
|
||||
- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback
|
||||
rows (#5096 follow-up, never done). Call sites include the mobile snapshot
|
||||
path (`pty-connection.ts:2861`) and sleep/hibernate serialization.
|
||||
- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped
|
||||
buffers (repro harness exists).
|
||||
- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s
|
||||
tick; small, part of the same program).
|
||||
|
||||
### 5. Producer-side PTY flow control
|
||||
|
||||
Ack-driven pause/resume of the actual PTY through the daemon protocol
|
||||
(node-pty supports it), watermarks per the xterm.js flow-control guide
|
||||
(≤500 KB). Converts flood-induced buffered lag into shell blocking — the
|
||||
correct physics. Sequence after #7139 lands (interacts with its drain pacing).
|
||||
|
||||
Design (2026-07-03, implement after the term-speed-2 revival merges —
|
||||
same files):
|
||||
|
||||
- Signal source: main already tracks per-pty pending + in-flight
|
||||
(`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a
|
||||
pty's pending exceeds HIGH (256 KB), main asks the producer to pause;
|
||||
below LOW (32 KB), resume.
|
||||
- Producer side: two new protocol notifications (`pausePty`/`resumePty`,
|
||||
protocol vNext, version-gated like `supportsIncrementalCheckpoints`);
|
||||
daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the
|
||||
pty fd, kernel buffer fills, the shell blocks on write: true kernel
|
||||
backpressure, identical physics to VS Code's 100k/5k design.
|
||||
`LocalPtyProvider` calls pause/resume directly.
|
||||
- Safety invariants: (1) failsafe auto-resume after 5 s regardless of
|
||||
watermark, so a lost resume can never wedge a shell; (2) resume on
|
||||
detach/exit/kill/daemon-reconnect; (3) pause must not suppress the
|
||||
interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the
|
||||
HIGH watermark is only reachable during genuine floods where echo is
|
||||
already queued; (4) PTY reads never stop for model/tail ingestion
|
||||
(term-speed-2 invariant #1) — pause gates the fd read, so daemon-side
|
||||
emulator state pauses with it, which is correct (state = what was read).
|
||||
- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit
|
||||
cleanup, plus an e2e pressure scenario asserting bounded main memory and
|
||||
a blocked producer (`yes` exits promptly on SIGINT while paused).
|
||||
|
||||
### 6. Extend the measurement rig
|
||||
|
||||
- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops
|
||||
at the parser reply, before paint).
|
||||
- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling
|
||||
at 0/1/5 agents (goal-3 metric).
|
||||
- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in
|
||||
main/daemon/renderer behind a debug flag for pipeline attribution.
|
||||
|
||||
### 7. utilityProcess terminal router (structural endgame; gated on data)
|
||||
|
||||
An Electron UtilityProcess owns the daemon socket and hands each renderer a
|
||||
MessagePort — VS Code's topology while keeping Orca's detached daemon (warm
|
||||
reattach). Takes main off the terminal data path entirely; daemon-side
|
||||
history persistence falls out naturally. Prototype only after baselines show
|
||||
how much tail latency lives in the main hop.
|
||||
|
||||
### 8. Production P90 telemetry
|
||||
|
||||
Sampled keypress→echo latency + long-task/stall counts from real users;
|
||||
defines the success criterion and becomes the permanent regression gate.
|
||||
Design after the local rig stabilizes so the metrics match.
|
||||
|
||||
## Benchmark protocol
|
||||
|
||||
`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any
|
||||
terminal:
|
||||
|
||||
- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only
|
||||
after the parser reaches the query, so it proxies the input pipeline
|
||||
without keystroke injection.
|
||||
- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`,
|
||||
`agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints —
|
||||
and labeled-pathological `styles-stress`), each run ended by a DSR fence so
|
||||
xterm.js-class ingest queues can't flatter the result.
|
||||
- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI
|
||||
stream: "typing while the agent works," quantified.
|
||||
|
||||
Rules: same machine, AC power, comparable window size, no tmux/screen, hands
|
||||
off the keyboard during runs. Never compare numbers across machines.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
now: [1] baselines [2] #7153 testing (parallel)
|
||||
next: [3] term-speed-2 revival (dedicated session)
|
||||
parallel: [4] stall fixes, [6] rig extensions
|
||||
after 2/3: [5] flow control
|
||||
gated: [7] utility router [8] telemetry
|
||||
```
|
||||
|
||||
BMW-group crash work remains the team's priority gate above all of this
|
||||
(#7150's wedge guards overlap it); this plan runs measurement and revival
|
||||
prep in parallel without displacing it.
|
||||
|
||||
## Findings log
|
||||
|
||||
### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/)
|
||||
|
||||
Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty
|
||||
1.3.1; iTerm2 not installed, VS Code pending):
|
||||
|
||||
| metric | Orca prod | Terminal.app | Ghostty |
|
||||
|---|---|---|---|
|
||||
| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 |
|
||||
| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 |
|
||||
| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 |
|
||||
| ascii-log fenced throughput | 13 MB/s | 39 | 93 |
|
||||
|
||||
Decomposition of the 51× agent-tui gap — both pipeline ends are fast:
|
||||
|
||||
- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s**
|
||||
(`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem.
|
||||
- Daemon `Session` ingest (emulator + pending-output recording + fanout):
|
||||
agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`,
|
||||
`ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem.
|
||||
|
||||
Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms,
|
||||
so the utilityProcess router is deprioritized by data; (2) the crisis is
|
||||
queueing between daemon egress and renderer parse completion — main
|
||||
per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after
|
||||
renderer write callbacks, so renderer slowness throttles delivery
|
||||
multiplicatively), and renderer per-chunk layers above xterm; (3) the
|
||||
agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain
|
||||
text inside Orca while being equal-cost everywhere else — profile it in the
|
||||
renderer first (task #9).
|
||||
|
||||
### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only)
|
||||
|
||||
Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode
|
||||
overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** —
|
||||
the cooperative-drain branch does not move the under-load class. In
|
||||
hindsight this is structural: DSR replies are ordered within the output
|
||||
stream, so the metric measures output-queue depth; #7139 paces draining to
|
||||
protect input-send responsiveness but cannot reorder the queue. Implications:
|
||||
(1) the 134 ms-class number is fixed only by shrinking the queue (producer
|
||||
flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's
|
||||
own wins (freeze class, bounded memory, input-loss guards) must be validated
|
||||
with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs
|
||||
are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom.
|
||||
|
||||
### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9)
|
||||
|
||||
The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained
|
||||
at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background)
|
||||
regardless of parse speed. Isolation bench (fake timers, instant-parse
|
||||
terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`,
|
||||
`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching
|
||||
prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only
|
||||
when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the
|
||||
sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's
|
||||
`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks
|
||||
to the queue, so floods always ride the drip.
|
||||
|
||||
Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** —
|
||||
a pacer re-arms a 0ms drain when xterm's write callback confirms the batch
|
||||
parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling:
|
||||
27 → **117.6 MB/s** (parse-limited). Background cadence deliberately
|
||||
unchanged (protects the focused pane; hidden panes are term-speed-2's job).
|
||||
`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of
|
||||
#7139 preserved; its budget-yield test still passes). 621 tests green.
|
||||
|
||||
Open follow-ups from this attribution: (a) end-to-end dev verification (in
|
||||
progress); (b) whether main's `background:true` delivery marking demotes
|
||||
visible-pane floods to the background drip — check
|
||||
`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log
|
||||
gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect
|
||||
scanning; profile after (a).
|
||||
|
||||
### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk
|
||||
|
||||
Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered
|
||||
probes (renderer scheduler counters → main whole-method timer → per-section
|
||||
timers → targeted micro-benches) attributed it fully:
|
||||
|
||||
- Renderer receives only ~350–770 KB/s — it is **starved**, not slow.
|
||||
- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the
|
||||
flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk).
|
||||
- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless
|
||||
track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the
|
||||
pty-record tail block.
|
||||
- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame
|
||||
containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32×
|
||||
the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer`
|
||||
materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and
|
||||
`finalizeRetainedTerminalRows` allocates them all again plus runs a
|
||||
trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes
|
||||
per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code
|
||||
frame (cursor-up + erase-below) takes this path; plain logs don't — which
|
||||
is exactly the measured agent-tui vs ascii asymmetry.
|
||||
|
||||
Chain: TUI flood → O(tail) work per chunk in main → main event loop
|
||||
saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s →
|
||||
deep queue → 134 ms DSR-under-load.
|
||||
|
||||
Fix (in progress): run the existing algorithm on a lazy suffix window (the
|
||||
cursor's maximum upward reach, computed from the chunk) with the untouched
|
||||
prefix shared by reference; differential fuzz test proves output equality
|
||||
against the original implementation. Worst case (pathological full-height
|
||||
cursor-up) falls back to today's cost.
|
||||
|
||||
### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued
|
||||
|
||||
Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same
|
||||
protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under-
|
||||
load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the
|
||||
first time, but the pipeline is still far from the renderer's 27–117 MB/s
|
||||
capacity — another main-side consumer remains hot.
|
||||
|
||||
Next cycle (exact recipe): re-apply the whole-method main probe
|
||||
(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed
|
||||
build. If onPtyData still dominates, the remaining O(tail)/per-chunk
|
||||
suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full
|
||||
tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in
|
||||
dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured);
|
||||
(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If
|
||||
onPtyData no longer dominates, probe the main→renderer delivery batching
|
||||
next. The probe/bench cycle is mechanical: relaunch dev
|
||||
(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command
|
||||
"<bench> --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log.
|
||||
|
||||
### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale
|
||||
|
||||
Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still
|
||||
~90% of main's event loop (~930 ms/s). Bucket split per second:
|
||||
**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed
|
||||
fix confirmed), normalize ≈ 7, preview ≈ 0.
|
||||
|
||||
Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its
|
||||
callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText`
|
||||
joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both
|
||||
(another ~512KB of string allocation per chunk) and runs multi-pattern
|
||||
blocked/ready scans (`findTerminalWaitBlockedSignal`,
|
||||
`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text)
|
||||
— all to timestamp `waitBlockedAt` for `terminal wait`.
|
||||
|
||||
Fix design (next session): blocked/ready prompts are end-anchored — an
|
||||
actionable prompt is at the END of output. (1) Run the check on a bounded
|
||||
suffix of the wait text (last ~64 lines / 16KB) instead of the full tail;
|
||||
(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small
|
||||
carry for split keywords) can contain a blocked keyword; (3) build the two
|
||||
wait texts only when the check runs. Verification mirrors the windowed-tail
|
||||
pattern: keep the full-text check as reference + differential fuzz over
|
||||
randomized tails/prompts (split-across-chunks cases included — the
|
||||
`appendCandidateSignal` ordering semantics at :23146 must be preserved),
|
||||
plus the terminal-wait contract tests. Expected effect: removes ~85% of
|
||||
remaining onPtyData cost; combined with the two landed fixes should
|
||||
finally unlock the pipeline toward the renderer's measured 27–117 MB/s.
|
||||
|
||||
### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency
|
||||
|
||||
Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243,
|
||||
windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label
|
||||
dev-blockedfix, same protocol/config as prior dev rows:
|
||||
|
||||
| metric | pre-fix dev | +tail fix | +blocked fix |
|
||||
|---|---|---|---|
|
||||
| agent-tui MB/s | 0.7 | 1.0 | **11.5** |
|
||||
| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** |
|
||||
| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** |
|
||||
| ascii-log MB/s | 6.4 | 4.7 | **9.6** |
|
||||
|
||||
The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The
|
||||
throttled blocked-check delivered the predicted ~85% cut. Dev mode carries
|
||||
~2× overhead vs prod, so the prod build should land near ~10ms DSR-under-
|
||||
load — from the 134ms baseline (~13×) — pending a packaged-build rerun.
|
||||
Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain
|
||||
ticks + xterm 12ms slices), which flow control (#6) does not target;
|
||||
re-evaluate the "within 10× of Terminal.app" goal line after a prod
|
||||
measurement. Next: term-speed-2 revival (#4), then flow control (#6).
|
||||
|
||||
### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate)
|
||||
|
||||
`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files,
|
||||
typecheck clean, ~2,776 targeted tests green, all three of our fixes
|
||||
verified present, chain features present and kill-switched (subagent's
|
||||
six review risks recorded in its report). Bench verdict on the revived
|
||||
build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35%
|
||||
unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round
|
||||
proved the kill switches are NOT the cost) and idle p50 doubled.
|
||||
|
||||
Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s
|
||||
≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon
|
||||
CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×)
|
||||
on the revive branch** (`session-ingest-throughput.bench.test.ts`,
|
||||
ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator
|
||||
restructure (scanner classes / query-reply forwarding / view-attribute
|
||||
responder) added per-byte cost to the daemon hot path. Chunks reaching
|
||||
main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger).
|
||||
|
||||
NEXT (fast inner loop — pure unit bench, no app restarts): on
|
||||
revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs
|
||||
7839fb9db, find the per-chunk scanner cost, restore our bounded-parser
|
||||
fast paths (the daemon emulator must never pay per-byte JS scanning for
|
||||
bytes that contain no ESC — same pre-filter pattern as the blocked-check
|
||||
keyword bypass), verify with the ingest bench back at ~100 MB/s, then
|
||||
full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to
|
||||
orca-performance. A residual renderer-side share is possible once the
|
||||
daemon is fixed — re-attribute after.
|
||||
|
||||
Merge gate: revive branch merges only at ≥ blockedfix numbers.
|
||||
|
||||
**RETRACTION (2026-07-03, later):** the daemon conviction above was a
|
||||
confounded measurement — the 39–48 MB/s ingest runs executed while a dev
|
||||
app was still running. On a quiet machine the revive branch ingests at
|
||||
**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s
|
||||
vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end
|
||||
revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs
|
||||
were load-controlled, and unit benches show up to 2.6× machine-load
|
||||
variance. Scanner pre-filters landed anyway on revive (71c89da9b;
|
||||
strictly positive, 641 daemon tests green).
|
||||
|
||||
**New measurement protocol (mandatory from here):** quiet machine (no dev
|
||||
apps or benches concurrent), paired A/B runs back-to-back alternating
|
||||
branches, n≥2 per side, report spread not just p50. The merge-gate
|
||||
comparison (blockedfix vs revive) must be redone under this protocol
|
||||
before any verdict. Next: run the controlled A/B; if the delta
|
||||
disappears, merge revive into orca-performance and proceed to flow
|
||||
control (#6); if it persists, resume attribution renderer-side (probe
|
||||
pty-connection dataCallback additions per chunk).
|
||||
|
||||
### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance
|
||||
|
||||
Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui +
|
||||
DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive
|
||||
6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50
|
||||
tied, p99 better on revive, throughput within overlapping noise (revive2's
|
||||
3.6 followed two runtime-busy create failures). The earlier "35%
|
||||
regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs
|
||||
11.5 yesterday — dev benches carry ~2x day-to-day machine variance;
|
||||
absolute dev numbers are only comparable within one A/B session.
|
||||
|
||||
Merged revive/term-speed-2 → orca-performance; typecheck clean, 288
|
||||
post-merge spot tests green. orca-performance now = main-ish base + #7153
|
||||
+ three perf fixes + full term-speed-2 chain (kill-switched, default ON)
|
||||
+ scanner pre-filters. Extended user testing now covers everything.
|
||||
Remaining from the revival agent's risk list: gate×drain e2e specs
|
||||
(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them.
|
||||
Next: producer flow control (#6) per design §5; prod packaged-build bench
|
||||
for the real headline numbers.
|
||||
|
||||
### 2026-07-03 — flow control merged; goal-state accounting
|
||||
|
||||
Producer flow control merged to orca-performance (348aeb325): protocol
|
||||
v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData,
|
||||
node-pty kernel backpressure, 5s daemon-side lost-resume failsafe +
|
||||
main-side pause re-assert, resume on every teardown path, version-gated
|
||||
(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED`
|
||||
(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests
|
||||
green.
|
||||
|
||||
**Definition-of-done accounting:**
|
||||
- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s
|
||||
and DSR-load p50 161→18.8 dev, results committed).
|
||||
- term-speed-2: REVIVED AND MERGED (A/B gate passed).
|
||||
- Flow control: IMPLEMENTED AND MERGED.
|
||||
- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged
|
||||
RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod
|
||||
projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The
|
||||
remaining gap is structural cadence (daemon 8ms batch, renderer drain
|
||||
ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the
|
||||
waste class this initiative eliminated. Prod verification path:
|
||||
electron-vite preview CANNOT host the bench (CLI-created panes are not
|
||||
adopted by the preview window's renderer → no ACKs → pending-cap drop;
|
||||
two attempts, documented) — measure on the next packaged RC cut from
|
||||
orca-performance using the committed rig + protocol instead.
|
||||
|
||||
**Deferred, ordered:** (1) sync orca-performance with main — conflicts
|
||||
incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era
|
||||
`Metadata=12`; renumber chain side, audit mobile/web stream consumers);
|
||||
(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) —
|
||||
gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig
|
||||
extensions + P90 telemetry (tasks #3/#8).
|
||||
|
||||
### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers)
|
||||
|
||||
Same rig, same protocol, same machine as the 1.4.91 baseline:
|
||||
|
||||
| metric | 1.4.91 baseline | v1.4.121-rc.0 | change |
|
||||
|---|---|---|---|
|
||||
| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) |
|
||||
| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x |
|
||||
| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x |
|
||||
| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x |
|
||||
| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x |
|
||||
| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x |
|
||||
| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x |
|
||||
|
||||
Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at
|
||||
a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse
|
||||
CPU (prod ≈ dev for both latency and throughput; the pipeline is
|
||||
cadence-bound, so faster prod code changes nothing). That uniform cap
|
||||
also explains plain-text dipping slightly below baseline: ascii/cjk used
|
||||
to run unpaced ahead of the old scheduler; now everything flows through
|
||||
the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app
|
||||
under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at
|
||||
parity. The remaining 4x is the named cadence stack (daemon 8 ms batch,
|
||||
scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) —
|
||||
next lever, tunable, tracked as follow-up. p99 tail (the freeze class)
|
||||
is 29.7 ms — users cannot perceive it.
|
||||
|
||||
Caveat: measured on the user's live app (this session active in it);
|
||||
idle p99 118 ms reflects that activity, not the terminal path.
|
||||
|
||||
### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig)
|
||||
|
||||
| metric | Orca v1.4.121-rc.0 | VS Code | verdict |
|
||||
|---|---|---|---|
|
||||
| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster |
|
||||
| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster |
|
||||
| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail |
|
||||
| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% |
|
||||
| cjk-emoji | 12.2 | 11.3 | tie |
|
||||
| agent-tui | 11.2 | 11.7 | tie |
|
||||
| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x |
|
||||
|
||||
Orca now beats or ties the best-known xterm.js terminal on 5 of 6
|
||||
metrics — including 16x at idle (what users feel all day) and 5x on
|
||||
SGR-heavy output — and holds a better p99 tail under load. Throughput
|
||||
sits at the shared engine ceiling (~9-12 MB/s), confirming the class
|
||||
limit.
|
||||
|
||||
The one loss (load p50) has a clean mechanism: VS Code's producer flow
|
||||
control caps unacked output at ~100KB, so its standing queue is
|
||||
~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue
|
||||
(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window
|
||||
(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested
|
||||
setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel
|
||||
drain scheduling (sub-ms re-arm; also raises the throughput ceiling);
|
||||
(2) tighter effective in-flight window on the renderer delivery path.
|
||||
Target: VS Code's ~7ms class or below without giving back throughput.
|
||||
|
||||
### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms
|
||||
|
||||
Lever results (dev, 3MB protocol, same session):
|
||||
- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the
|
||||
~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util)
|
||||
there is no standing queue. Kept (correct, removes a real clamp).
|
||||
- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon
|
||||
STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS):
|
||||
**p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1).
|
||||
Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239
|
||||
batcher+pty tests green after timing updates.
|
||||
|
||||
Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should
|
||||
land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining
|
||||
fixed-latency terms are renderer/xterm-internal (12ms parse slices).
|
||||
Note: main's interactive bypass (input-gated) means real keystroke echo
|
||||
skips batching entirely — the DSR metric understates real typing
|
||||
responsiveness; VS Code measured on the same freight path, comparison
|
||||
fair.
|
||||
|
||||
Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect
|
||||
~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in
|
||||
reach).
|
||||
|
||||
### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green
|
||||
|
||||
terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/
|
||||
title side effects live while parked), terminal-sleep-wake-restore
|
||||
(output restored + input accepted after wake), terminal-parked-memory
|
||||
(renderer memory released on park; views retained when kill-switched
|
||||
off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay —
|
||||
the revival's top flagged risk — now has e2e coverage on the exact
|
||||
branch the RC ships from. Remaining garble-hardening: differential
|
||||
hide/reveal fuzz harness (next build), reveal-time seq diagnostics.
|
||||
|
||||
## Success criteria (baseline-relative; finalize after task 1)
|
||||
|
||||
- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same
|
||||
box; zero DSR timeouts (today's freeze class).
|
||||
- Fenced agent-tui throughput ≥ VS Code on the same box.
|
||||
- Idle RSS with 0–1 agents materially down (target set after the memory
|
||||
harness lands; hidden-pane parking is the main lever).
|
||||
- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood.
|
||||
- Production P90 typing latency down and monitored continuously.
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.135-rc.2",
|
||||
"version": "1.4.135-rc.3",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -81,6 +81,7 @@
|
||||
"test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs",
|
||||
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
|
||||
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
|
||||
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
|
||||
@@ -91,6 +92,7 @@
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -257,7 +259,8 @@
|
||||
"patchedDependencies": {
|
||||
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
|
||||
"@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch",
|
||||
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch"
|
||||
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
|
||||
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch"
|
||||
}
|
||||
},
|
||||
"reactDoctor": {
|
||||
|
||||
Generated
+5
-2
@@ -8,6 +8,9 @@ patchedDependencies:
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287':
|
||||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
|
||||
'@xterm/addon-serialize@0.15.0-beta.287':
|
||||
hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9
|
||||
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
@@ -36,7 +39,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -9288,7 +9291,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
|
||||
import type { DaemonTransientFact } from './types'
|
||||
|
||||
function createRelay() {
|
||||
const emitted: { sessionId: string; fact: DaemonTransientFact }[] = []
|
||||
const relay = new BackgroundTransientFactRelay((sessionId, fact) =>
|
||||
emitted.push({ sessionId, fact })
|
||||
)
|
||||
return { relay, emitted }
|
||||
}
|
||||
|
||||
describe('BackgroundTransientFactRelay', () => {
|
||||
it('emits a bell fact for a backgrounded session', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
relay.onSessionData('s1', 'build output\x07more')
|
||||
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }])
|
||||
})
|
||||
|
||||
it('keeps OSC escape state across chunks — a title terminator BEL is not a bell', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
relay.onSessionData('s1', '\x1b]0;my working title')
|
||||
relay.onSessionData('s1', ' continued\x07')
|
||||
expect(emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('emits nothing for sessions that are not backgrounded', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.onSessionData('s1', 'ding\x07')
|
||||
expect(emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('emits command-finished with the OSC 133;D exit code', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
relay.onSessionData('s1', '\x1b]133;D;0\x07')
|
||||
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }])
|
||||
})
|
||||
|
||||
it('stops emitting after un-background and reports the toggle as a state change', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
expect(relay.setSessionBackground('s1', true)).toBe(true)
|
||||
expect(relay.setSessionBackground('s1', true)).toBe(false)
|
||||
expect(relay.setSessionBackground('s1', false)).toBe(true)
|
||||
expect(relay.setSessionBackground('s1', false)).toBe(false)
|
||||
relay.onSessionData('s1', 'ding\x07')
|
||||
expect(emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the tracker on session exit', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
relay.onSessionExit('s1')
|
||||
expect(relay.isBackgrounded('s1')).toBe(false)
|
||||
relay.onSessionData('s1', 'ding\x07')
|
||||
expect(emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('never arms the stale-working-title timer (titles are main-authoritative)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
// A working-spinner title followed by title-less output would arm the
|
||||
// 3s stale timer if titles were being tracked.
|
||||
relay.onSessionData('s1', '\x1b]0;⠋ Claude\x07')
|
||||
relay.onSessionData('s1', 'output without titles')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(emitted).toEqual([])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Daemon-side transient-fact scanning for backgrounded sessions.
|
||||
*
|
||||
* While a session is backgrounded (its pane hidden in the renderer), the
|
||||
* daemon→main stream copy may be keep-tail thinned under backlog — but the
|
||||
* notification-bearing facts inside those bytes must never be lost. This
|
||||
* relay runs the SAME shared scanners main uses (terminal-side-effect
|
||||
* authority doc: semantics must not drift) over every raw chunk BEFORE any
|
||||
* drop decision, and emits compact transientFact stream events in byte order.
|
||||
* Main suppresses its own copies of these four scanners between the
|
||||
* sessionBackgroundMarker handoffs, so no fact double-fires or goes missing.
|
||||
*
|
||||
* Title/agent-status facts are deliberately NOT relayed: they converge from
|
||||
* the delivered kept tail (stale-working-title timer, snapshot-restores-title
|
||||
* -state) and main fuses them with synthetic spinner frames the daemon never
|
||||
* sees.
|
||||
*/
|
||||
import {
|
||||
createTerminalTitleTracker,
|
||||
type TerminalTitleTracker
|
||||
} from '../../shared/terminal-output-side-effects'
|
||||
import type { DaemonTransientFact } from './types'
|
||||
|
||||
// Kill switch for the whole background keep-tail mechanism (thinning +
|
||||
// daemon-side fact authority): ORCA_DAEMON_BACKGROUND_STREAM_DROP=0.
|
||||
export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND_STREAM_DROP !== '0'
|
||||
|
||||
export class BackgroundTransientFactRelay {
|
||||
private trackersBySessionId = new Map<string, TerminalTitleTracker>()
|
||||
private emitFact: (sessionId: string, fact: DaemonTransientFact) => void
|
||||
|
||||
constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) {
|
||||
this.emitFact = emitFact
|
||||
}
|
||||
|
||||
isBackgrounded(sessionId: string): boolean {
|
||||
return this.trackersBySessionId.has(sessionId)
|
||||
}
|
||||
|
||||
backgroundedSessionIdSuffixes(): string[] {
|
||||
return Array.from(this.trackersBySessionId.keys(), (id) => id.slice(-10))
|
||||
}
|
||||
|
||||
/** Returns false when this was a no-op (already in the requested state) so
|
||||
* the caller can skip a duplicate handoff marker — resyncs after adoption
|
||||
* re-send the whole background set. */
|
||||
setSessionBackground(sessionId: string, background: boolean): boolean {
|
||||
if (background === this.isBackgrounded(sessionId)) {
|
||||
return false
|
||||
}
|
||||
if (background) {
|
||||
this.trackersBySessionId.set(
|
||||
sessionId,
|
||||
createTerminalTitleTracker({
|
||||
onBell: () => this.emitFact(sessionId, { kind: 'bell' }),
|
||||
onCommandFinished: (exitCode) =>
|
||||
this.emitFact(sessionId, { kind: 'command-finished', exitCode }),
|
||||
// Note: recreating the tracker on each background toggle resets the
|
||||
// PR-link dedup memory, so a link re-printed across toggles can
|
||||
// re-fire — consumers treat pr-link as a latest-association update.
|
||||
onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }),
|
||||
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' })
|
||||
})
|
||||
)
|
||||
} else {
|
||||
this.disposeTracker(sessionId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Prime a fresh tracker's cross-chunk carry with the emulator's dangling
|
||||
* incomplete escape at handoff time, so a sequence split across the
|
||||
* background toggle neither mints a phantom bell nor loses its fact. A
|
||||
* partial tail contains no complete sequence, so this can never fire. */
|
||||
seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void {
|
||||
if (partialEscapeTailAnsi.length > 0) {
|
||||
this.trackersBySessionId
|
||||
.get(sessionId)
|
||||
?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery —
|
||||
* facts must be captured even when the chunk is later keep-tail dropped. */
|
||||
onSessionData(sessionId: string, data: string): void {
|
||||
// titleScanData:'' skips title extraction (titles stay main-authoritative)
|
||||
// and keeps the stale-working-title timer permanently unarmed — only the
|
||||
// four transient scanners consume the chunk.
|
||||
this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' })
|
||||
}
|
||||
|
||||
onSessionExit(sessionId: string): void {
|
||||
this.disposeTracker(sessionId)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const sessionId of Array.from(this.trackersBySessionId.keys())) {
|
||||
this.disposeTracker(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private disposeTracker(sessionId: string): void {
|
||||
this.trackersBySessionId.get(sessionId)?.dispose()
|
||||
this.trackersBySessionId.delete(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,9 @@ async function main(): Promise<void> {
|
||||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
process.send({ type: 'ready' })
|
||||
// Why: Windows has no cheap OS query for a child's start time, so the
|
||||
// daemon self-reports it here for the pid file's pid-recycling guard.
|
||||
process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 })
|
||||
}
|
||||
daemonLog.log('ready')
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Error classes shared across the daemon protocol boundary (client, server,
|
||||
// host). Split from types.ts, which is capped for wire-shape declarations.
|
||||
|
||||
export class TerminalAttachCanceledError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Attach canceled for session ${sessionId}`)
|
||||
this.name = 'TerminalAttachCanceledError'
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonProtocolError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'DaemonProtocolError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Session not found: ${sessionId}`)
|
||||
this.name = 'SessionNotFoundError'
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
parseLinuxBootTimeSeconds,
|
||||
parseLinuxProcStartTicks,
|
||||
parseDaemonPidFile,
|
||||
startTimeMatches
|
||||
parseWindowsProcessIdentityJson,
|
||||
startTimeMatches,
|
||||
startTimesWithinTolerance
|
||||
} from './daemon-health'
|
||||
import type { SubprocessHandle } from './session'
|
||||
|
||||
@@ -125,6 +127,26 @@ describe('daemon health', () => {
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a hello-rejected daemon as rejected, not unreachable', async () => {
|
||||
// Why: 'rejected' means the daemon answered and refused adoption — the
|
||||
// launcher may replace it. 'unreachable' also covers a wedged-but-live
|
||||
// daemon, which must never be replaced while its pipe accepts connections.
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
writeFileSync(tokenPath, 'not-the-daemon-token', { mode: 0o600 })
|
||||
await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('rejected')
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
|
||||
} finally {
|
||||
await server.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not unlink a live socket when the pid file does not match this daemon', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
@@ -286,6 +308,42 @@ describe('startTimeMatches', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWindowsProcessIdentityJson', () => {
|
||||
it('parses command line and start time from the CIM query output', () => {
|
||||
expect(
|
||||
parseWindowsProcessIdentityJson(
|
||||
'{"cmd":"Orca.exe daemon-entry.js","start":1700000000000}\r\n'
|
||||
)
|
||||
).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: 1_700_000_000_000 })
|
||||
})
|
||||
|
||||
it('returns a null start time when CreationDate was unavailable', () => {
|
||||
expect(
|
||||
parseWindowsProcessIdentityJson('{"cmd":"Orca.exe daemon-entry.js","start":null}')
|
||||
).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: null })
|
||||
})
|
||||
|
||||
it('returns null for a missing process or inaccessible command line', () => {
|
||||
expect(parseWindowsProcessIdentityJson('')).toBeNull()
|
||||
expect(parseWindowsProcessIdentityJson(' \r\n')).toBeNull()
|
||||
expect(parseWindowsProcessIdentityJson('{"cmd":null,"start":123}')).toBeNull()
|
||||
expect(parseWindowsProcessIdentityJson('not-json')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTimesWithinTolerance', () => {
|
||||
it('fails open when either side is null', () => {
|
||||
expect(startTimesWithinTolerance(null, 1_700_000_000_000, 1_500)).toBe(true)
|
||||
expect(startTimesWithinTolerance(1_700_000_000_000, null, 1_500)).toBe(true)
|
||||
expect(startTimesWithinTolerance(null, null, 1_500)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches within tolerance and rejects outside it', () => {
|
||||
expect(startTimesWithinTolerance(1_700_000_001_000, 1_700_000_000_000, 1_500)).toBe(true)
|
||||
expect(startTimesWithinTolerance(1_700_000_005_000, 1_700_000_000_000, 1_500)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killStaleDaemon pid identity guards', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
|
||||
@@ -24,8 +24,17 @@ const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000
|
||||
const KILL_WAIT_MS = 3_000
|
||||
const KILL_POLL_MS = 100
|
||||
const START_TIME_TOLERANCE_MS = 1_500
|
||||
// Why: on Windows the pid file's startedAtMs is the daemon's self-reported
|
||||
// Node start time, while verification reads the OS process creation time —
|
||||
// the gap between them is the exe bootstrap, which AV/disk pressure can
|
||||
// stretch to seconds. Pid recycling differs by minutes-to-days, so a wide
|
||||
// tolerance keeps the guard effective without false mismatches.
|
||||
const WIN32_START_TIME_TOLERANCE_MS = 10_000
|
||||
|
||||
export type DaemonHealth = 'healthy' | 'unreachable' | 'pty-spawn-unhealthy'
|
||||
// 'rejected' means the daemon answered and refused the handshake (bad token,
|
||||
// foreign protocol) — it can never be adopted, unlike 'unreachable', which
|
||||
// also covers a live-but-wedged daemon that simply missed the RPC budget.
|
||||
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
|
||||
|
||||
type ParsedDaemonPid = {
|
||||
pid: number
|
||||
@@ -134,13 +143,13 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis
|
||||
try {
|
||||
message = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
settle('unreachable')
|
||||
settle('rejected')
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
if (!(message as HelloResponse).ok) {
|
||||
settle('unreachable')
|
||||
settle('rejected')
|
||||
return
|
||||
}
|
||||
// Why: a protocol-live daemon with a stale cwd or node-pty helper
|
||||
@@ -371,6 +380,10 @@ export function getProcessStartedAtMs(pid: number): number | null {
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Why: the only OS source is a CIM query costing a powershell spawn —
|
||||
// too slow for this sync path. Windows pid files instead carry the
|
||||
// daemon's self-reported start time from its ready message, and
|
||||
// isDaemonProcess verifies it against CIM CreationDate asynchronously.
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -387,27 +400,61 @@ export function getProcessStartedAtMs(pid: number): number | null {
|
||||
}
|
||||
|
||||
export function startTimeMatches(pid: number, expectedStartedAtMs: number | null): boolean {
|
||||
if (expectedStartedAtMs === null) {
|
||||
return startTimesWithinTolerance(
|
||||
getProcessStartedAtMs(pid),
|
||||
expectedStartedAtMs,
|
||||
START_TIME_TOLERANCE_MS
|
||||
)
|
||||
}
|
||||
|
||||
// Why: fail open on null — a pid file or OS query without a start time must
|
||||
// not veto an otherwise-matching daemon (adoption safety beats recycle safety).
|
||||
export function startTimesWithinTolerance(
|
||||
actualStartedAtMs: number | null,
|
||||
expectedStartedAtMs: number | null,
|
||||
toleranceMs: number
|
||||
): boolean {
|
||||
if (expectedStartedAtMs === null || actualStartedAtMs === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
const actualStartedAtMs = getProcessStartedAtMs(pid)
|
||||
if (actualStartedAtMs === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS
|
||||
return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= toleranceMs
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export type WindowsProcessIdentity = {
|
||||
commandLine: string
|
||||
startedAtMs: number | null
|
||||
}
|
||||
|
||||
export function parseWindowsProcessIdentityJson(stdout: string): WindowsProcessIdentity | null {
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { cmd?: unknown; start?: unknown }
|
||||
if (typeof parsed.cmd !== 'string' || !parsed.cmd) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
commandLine: parsed.cmd,
|
||||
startedAtMs:
|
||||
typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the only reliable command-line source on Windows is a CIM query, which
|
||||
// costs a full powershell.exe spawn (300-800ms cold, worse under Defender).
|
||||
// Async because the sync version measurably froze the Electron main thread at
|
||||
// startup for the whole spawn (benchmark: ~0.5s warm, 3s timeout cap cold).
|
||||
// Timed under ORCA_STARTUP_DIAGNOSTICS so the cold-start benchmark can
|
||||
// attribute startup cost to these checks.
|
||||
async function queryWindowsProcessCommandLine(pid: number): Promise<string | null> {
|
||||
// CreationDate rides along in the same spawn so start-time verification adds
|
||||
// zero extra process launches. Timed under ORCA_STARTUP_DIAGNOSTICS so the
|
||||
// cold-start benchmark can attribute startup cost to these checks.
|
||||
async function queryWindowsProcessIdentity(pid: number): Promise<WindowsProcessIdentity | null> {
|
||||
const startedAt = performance.now()
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
@@ -416,14 +463,17 @@ async function queryWindowsProcessCommandLine(pid: number): Promise<string | nul
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
|
||||
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
|
||||
`if ($p) { $start = $null; ` +
|
||||
`if ($p.CreationDate) { $start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
|
||||
`@{ cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3_000
|
||||
}
|
||||
)
|
||||
return stdout
|
||||
return parseWindowsProcessIdentityJson(stdout)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
@@ -450,15 +500,16 @@ async function isDaemonProcess(
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const output = await queryWindowsProcessCommandLine(pid)
|
||||
if (output === null) {
|
||||
const identity = await queryWindowsProcessIdentity(pid)
|
||||
if (identity === null) {
|
||||
return false
|
||||
}
|
||||
// Why: image names are too broad after PID reuse. Match the daemon entry
|
||||
// plus the exact socket/token args so we only kill the daemon for this
|
||||
// userData protocol endpoint.
|
||||
return (
|
||||
commandLineMatchesDaemon(output, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs)
|
||||
commandLineMatchesDaemon(identity.commandLine, socketPath, tokenPath) &&
|
||||
startTimesWithinTolerance(identity.startedAtMs, startedAtMs, WIN32_START_TIME_TOLERANCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -485,7 +536,7 @@ async function isDaemonProcess(
|
||||
|
||||
async function getDaemonCommandLine(pid: number): Promise<string | null> {
|
||||
if (process.platform === 'win32') {
|
||||
return queryWindowsProcessCommandLine(pid)
|
||||
return (await queryWindowsProcessIdentity(pid))?.commandLine ?? null
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,8 @@ const {
|
||||
isPackagedMock,
|
||||
probeSocketExistsMock,
|
||||
writeFileSyncMock,
|
||||
readFileSyncMock,
|
||||
unlinkSyncMock,
|
||||
netConnectMock,
|
||||
forkMock,
|
||||
checkDaemonHealthMock,
|
||||
@@ -34,6 +36,7 @@ const {
|
||||
isDaemonStaleForCurrentBundleMock,
|
||||
killStaleDaemonMock,
|
||||
getProcessStartedAtMsMock,
|
||||
parseDaemonPidFileMock,
|
||||
daemonClientMock,
|
||||
spawnerInstances,
|
||||
ensureRunningOverrides,
|
||||
@@ -51,6 +54,13 @@ const {
|
||||
|
||||
const probeSocketExistsMock = vi.fn((_path?: string) => false)
|
||||
const writeFileSyncMock = vi.fn()
|
||||
// Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive's
|
||||
// catch treats every legacy pid file as unreadable — matching the pre-fix
|
||||
// cleanup behavior every existing test was written against.
|
||||
const readFileSyncMock = vi.fn((): string => {
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
const unlinkSyncMock = vi.fn()
|
||||
const forkMock = vi.fn()
|
||||
const netConnectMock = vi.fn(() => {
|
||||
// Why: the real probeSocket() in daemon-init connects to the socket and
|
||||
@@ -83,7 +93,10 @@ const {
|
||||
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
||||
const isDaemonStaleForCurrentBundleMock = vi.fn(() => false)
|
||||
const killStaleDaemonMock = vi.fn(async () => true)
|
||||
const getProcessStartedAtMsMock = vi.fn(() => 1_000_000)
|
||||
const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000)
|
||||
const parseDaemonPidFileMock = vi.fn(
|
||||
(): { pid: number; startedAtMs: number | null } | null => null
|
||||
)
|
||||
|
||||
const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() {
|
||||
return {
|
||||
@@ -145,6 +158,8 @@ const {
|
||||
isPackagedMock,
|
||||
probeSocketExistsMock,
|
||||
writeFileSyncMock,
|
||||
readFileSyncMock,
|
||||
unlinkSyncMock,
|
||||
netConnectMock,
|
||||
forkMock,
|
||||
checkDaemonHealthMock,
|
||||
@@ -154,6 +169,7 @@ const {
|
||||
isDaemonStaleForCurrentBundleMock,
|
||||
killStaleDaemonMock,
|
||||
getProcessStartedAtMsMock,
|
||||
parseDaemonPidFileMock,
|
||||
daemonClientMock,
|
||||
spawnerInstances,
|
||||
ensureRunningOverrides,
|
||||
@@ -214,7 +230,8 @@ vi.mock('electron', () => ({
|
||||
vi.mock('fs', () => ({
|
||||
mkdirSync: vi.fn(),
|
||||
existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'),
|
||||
unlinkSync: vi.fn(),
|
||||
unlinkSync: unlinkSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
|
||||
@@ -229,7 +246,8 @@ vi.mock('./daemon-health', () => ({
|
||||
healthCheckDaemon: healthCheckDaemonMock,
|
||||
isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock,
|
||||
killStaleDaemon: killStaleDaemonMock,
|
||||
getProcessStartedAtMs: getProcessStartedAtMsMock
|
||||
getProcessStartedAtMs: getProcessStartedAtMsMock,
|
||||
parseDaemonPidFile: parseDaemonPidFileMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({ DaemonClient: daemonClientMock }))
|
||||
@@ -358,6 +376,15 @@ async function importFresh() {
|
||||
daemonClientMock.mockClear()
|
||||
probeSocketExistsMock.mockClear()
|
||||
writeFileSyncMock.mockClear()
|
||||
readFileSyncMock.mockReset()
|
||||
readFileSyncMock.mockImplementation(() => {
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
unlinkSyncMock.mockClear()
|
||||
parseDaemonPidFileMock.mockReset()
|
||||
parseDaemonPidFileMock.mockReturnValue(null)
|
||||
getProcessStartedAtMsMock.mockReset()
|
||||
getProcessStartedAtMsMock.mockReturnValue(1_000_000)
|
||||
// Why: importing daemon-init *after* resetModules means the module-level
|
||||
// `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is
|
||||
// the only way to reliably exercise the "first-time init" path and the
|
||||
@@ -1551,7 +1578,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => {
|
||||
it('replaces a health-check-failing daemon when live sessions cannot be verified and the pipe is dead', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
@@ -1595,6 +1622,203 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(forkMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts an unresponsive daemon whose pipe still accepts connections (update-relaunch wedge)', async () => {
|
||||
// Why: the Windows update-relaunch regression — post-install disk/AV load
|
||||
// wedges the daemon past the 3s health budget AND the 5s hello budget of
|
||||
// the session-list re-verification, while its sessions are still alive.
|
||||
// The old fail-closed path killed the daemon here. A pipe that accepts a
|
||||
// raw connection proves the daemon is alive, so the launcher must adopt.
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {
|
||||
throw new Error('Hello response timed out')
|
||||
}),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
||||
// The raw pipe probe succeeds even though every RPC timed out.
|
||||
probeSocketExistsMock.mockReturnValue(true)
|
||||
netConnectMock.mockImplementationOnce(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'connect') {
|
||||
queueMicrotask(() => cb())
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeListener(event: string, cb: () => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
||||
return this
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => {
|
||||
// Why: 'rejected' means the daemon answered and refused the handshake —
|
||||
// it can never be adopted, so keeping it alive would strand the app with
|
||||
// no terminals forever. Replacement stays the only recovery.
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {
|
||||
throw new Error('Hello rejected')
|
||||
}),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('rejected')
|
||||
probeSocketExistsMock.mockReturnValue(true)
|
||||
forkMock.mockImplementationOnce(() => ({
|
||||
pid: 12345,
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => cb({ type: 'ready' }))
|
||||
}
|
||||
return this
|
||||
},
|
||||
off() {
|
||||
return this
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
unref: vi.fn()
|
||||
}))
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
||||
FAKE_RUNTIME_DIR,
|
||||
'/fake/socket',
|
||||
'/fake/token'
|
||||
)
|
||||
expect(forkMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => {
|
||||
// Why: the regression contract — a pid file with startedAtMs null (all
|
||||
// pre-fix Windows pid files) resolves launch identity to 'unknown'. With
|
||||
// a live daemon answering on the pipe, that must ADOPT, never replace.
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
getDaemonLaunchIdentityMock.mockReturnValueOnce('unknown')
|
||||
isPackagedMock.mockReturnValue(true)
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => {
|
||||
// Why: getProcessStartedAtMs has no cheap Windows implementation, so the
|
||||
// pid file's pid-recycling guard depends on the ready-message fallback.
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
||||
getProcessStartedAtMsMock.mockReturnValue(null)
|
||||
forkMock.mockImplementationOnce(() => ({
|
||||
pid: 12345,
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_700_000_123_456 }))
|
||||
}
|
||||
return this
|
||||
},
|
||||
off() {
|
||||
return this
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
unref: vi.fn()
|
||||
}))
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
|
||||
JSON.stringify({
|
||||
pid: 12345,
|
||||
startedAtMs: 1_700_000_123_456,
|
||||
entryPath: FAKE_DAEMON_ENTRY_PATH,
|
||||
appVersion: '1.2.3'
|
||||
}),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => {
|
||||
// Why: deleting a live-but-wedged legacy daemon's token file makes its
|
||||
// sessions permanently unadoptable — no future launch could authenticate.
|
||||
const mod = await importFresh()
|
||||
readFileSyncMock.mockReturnValue('{"pid":123}')
|
||||
// process.pid is guaranteed alive, so the liveness probe succeeds.
|
||||
parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null })
|
||||
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const legacyUnlinks = unlinkSyncMock.mock.calls.filter(
|
||||
([p]) => typeof p === 'string' && (p.includes('.token') || p.includes('.pid'))
|
||||
)
|
||||
expect(legacyUnlinks).toEqual([])
|
||||
})
|
||||
|
||||
it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => {
|
||||
const mod = await importFresh()
|
||||
readFileSyncMock.mockReturnValue('{"pid":123}')
|
||||
// Why: spy on process.kill so the liveness probe deterministically reports
|
||||
// "no such process" without depending on an unallocated real pid.
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw new Error('ESRCH')
|
||||
})
|
||||
parseDaemonPidFileMock.mockReturnValue({ pid: 999_999, startedAtMs: null })
|
||||
|
||||
try {
|
||||
await mod.initDaemonPtyProvider()
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
}
|
||||
|
||||
const tokenUnlinks = unlinkSyncMock.mock.calls.filter(
|
||||
([p]) => typeof p === 'string' && p.includes('.token')
|
||||
)
|
||||
expect(tokenUnlinks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('replaces a health-check-failing daemon when no live sessions would be lost', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
@@ -8,7 +8,7 @@ module-level spawner/adapter singletons must stay co-located so a future
|
||||
change cannot leave them drifting out of sync. */
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import {
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
getProcessStartedAtMs,
|
||||
checkDaemonHealth,
|
||||
isDaemonStaleForCurrentBundle,
|
||||
killStaleDaemon
|
||||
killStaleDaemon,
|
||||
parseDaemonPidFile
|
||||
} from './daemon-health'
|
||||
import {
|
||||
collectPinnedDaemonVersions,
|
||||
@@ -255,9 +256,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
// Why: a busy machine (e.g. right after an update) can time out the
|
||||
// health check while the daemon is alive and owning terminals. Killing
|
||||
// it would destroy every live session, so re-verify with a session list
|
||||
// first. Only a verified non-empty list preserves: a daemon that cannot
|
||||
// even list sessions cannot serve terminals, and replacing it is the
|
||||
// only recovery.
|
||||
// first.
|
||||
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
|
||||
if (liveSessionCount !== null && liveSessionCount > 0) {
|
||||
if (health === 'pty-spawn-unhealthy') {
|
||||
@@ -275,6 +274,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
)
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
// Why: on a Windows update relaunch the daemon can be wedged past every
|
||||
// RPC budget (final checkpoint flush + installer/AV disk pressure), so
|
||||
// both the health check AND the session list time out while sessions
|
||||
// are still alive — failing closed here is what killed those sessions.
|
||||
// A pipe that still accepts connections proves a live daemon: adopt it
|
||||
// and let the adapter reconnect once the daemon drains. 'rejected'
|
||||
// means the daemon answered and refused the handshake — it can never be
|
||||
// adopted, so replacement stays the only recovery.
|
||||
if (liveSessionCount === null && health !== 'rejected' && (await probeSocket(socketPath))) {
|
||||
console.warn(
|
||||
'[daemon] Preserving unresponsive daemon because its socket still accepts connections'
|
||||
)
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
|
||||
@@ -398,11 +411,19 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
// killStaleDaemon() can verify the pid still belongs to the daemon
|
||||
// we forked before SIGTERMing it. Prevents pid-recycling hazard
|
||||
// where the OS hands the daemon's old pid to an unrelated process.
|
||||
// Why the ready-message fallback: Windows has no cheap OS query
|
||||
// for start time, so the daemon self-reports it — without this the
|
||||
// recycling guard was permanently inert on win32.
|
||||
const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs
|
||||
writeFileSync(
|
||||
getDaemonPidPath(runtimeDir),
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid,
|
||||
startedAtMs: getProcessStartedAtMs(child.pid),
|
||||
startedAtMs:
|
||||
getProcessStartedAtMs(child.pid) ??
|
||||
(typeof selfReported === 'number' && Number.isFinite(selfReported)
|
||||
? selfReported
|
||||
: null),
|
||||
entryPath,
|
||||
appVersion: app.getVersion()
|
||||
}),
|
||||
@@ -821,6 +842,21 @@ export async function cleanupDaemonForProtocol(
|
||||
return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount }
|
||||
}
|
||||
|
||||
function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean {
|
||||
try {
|
||||
const parsed = parseDaemonPidFile(
|
||||
readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8')
|
||||
)
|
||||
if (!parsed) {
|
||||
return false
|
||||
}
|
||||
process.kill(parsed.pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPtyAdapter[]> {
|
||||
const adapters: DaemonPtyAdapter[] = []
|
||||
for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
|
||||
@@ -830,23 +866,27 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
|
||||
// Why: dead legacy daemons leave pid/token files behind forever (one per
|
||||
// protocol bump). A stale pid eventually gets recycled by an unrelated
|
||||
// process, turning any future identity check into a PowerShell spawn.
|
||||
// The socket is provably dead, so remove the leftovers — mirrors what
|
||||
// cleanupDaemonForProtocol already does for the current version.
|
||||
for (const stalePath of [
|
||||
getDaemonPidPath(runtimeDir, protocolVersion),
|
||||
getDaemonTokenPath(runtimeDir, protocolVersion)
|
||||
]) {
|
||||
try {
|
||||
unlinkSync(stalePath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
// Only clean up when the pid-file process is provably gone: a live
|
||||
// legacy daemon can transiently fail the 1s probe right after an update
|
||||
// (wedged event loop, exhausted pipe backlog), and deleting its token
|
||||
// file would make its sessions permanently unadoptable.
|
||||
if (!legacyDaemonProcessMayBeAlive(runtimeDir, protocolVersion)) {
|
||||
for (const stalePath of [
|
||||
getDaemonPidPath(runtimeDir, protocolVersion),
|
||||
getDaemonTokenPath(runtimeDir, protocolVersion)
|
||||
]) {
|
||||
try {
|
||||
unlinkSync(stalePath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
@@ -31,6 +32,8 @@ function createTestDir(): string {
|
||||
}
|
||||
|
||||
function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & {
|
||||
pause: ReturnType<typeof vi.fn<() => void>>
|
||||
resume: ReturnType<typeof vi.fn<() => void>>
|
||||
_simulateData: (data: string) => void
|
||||
_simulateExit: (code: number) => void
|
||||
} {
|
||||
@@ -43,6 +46,8 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & {
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
pause: vi.fn<() => void>(),
|
||||
resume: vi.fn<() => void>(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
forceKill: vi.fn(),
|
||||
signal: vi.fn(),
|
||||
@@ -185,6 +190,135 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('producer flow control', () => {
|
||||
it('routes pausePty/resumePty notifications to the daemon session subprocess', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
adapter.pauseProducer(id)
|
||||
await waitFor(() => lastSubprocess.pause.mock.calls.length > 0)
|
||||
|
||||
adapter.resumeProducer(id)
|
||||
await waitFor(() => lastSubprocess.resume.mock.calls.length > 0)
|
||||
})
|
||||
|
||||
it('sends pause/resume as fire-and-forget notifications on the current protocol', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
try {
|
||||
adapter.pauseProducer(id)
|
||||
adapter.resumeProducer(id)
|
||||
expect(notifySpy).toHaveBeenCalledWith('pausePty', { sessionId: id })
|
||||
expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id })
|
||||
} finally {
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('never sends pause/resume notifications on a legacy protocol version', () => {
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 18 })
|
||||
try {
|
||||
legacy.pauseProducer('legacy-session')
|
||||
legacy.resumeProducer('legacy-session')
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('owes paused sessions a resumePty on the next connect after a socket drop', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
adapter.pauseProducer(id)
|
||||
await waitFor(() => lastSubprocess.pause.mock.calls.length > 0)
|
||||
|
||||
// Drop the daemon out from under the adapter: the in-flight pause has no
|
||||
// matching resume anymore.
|
||||
await server.shutdown()
|
||||
await waitFor(() => !(adapter as unknown as { client: DaemonClient }).client.isConnected())
|
||||
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: (opts) => {
|
||||
lastSpawnOpts = opts
|
||||
lastSubprocess = createMockSubprocess()
|
||||
return lastSubprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
try {
|
||||
// Any reconnecting operation must flush the owed resume first.
|
||||
await adapter.listProcesses()
|
||||
expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id })
|
||||
} finally {
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('background stream thinning compatibility', () => {
|
||||
it('reports background state on the authoritative-snapshot protocol', () => {
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
try {
|
||||
adapter.setPtyBackgrounded('current-session', true)
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId: 'current-session',
|
||||
background: true
|
||||
})
|
||||
} finally {
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps preserved v19 sessions unthinned because their snapshots have no sequence', () => {
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 })
|
||||
try {
|
||||
legacy.setPtyBackgrounded('legacy-session', true)
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId: 'legacy-session',
|
||||
background: false
|
||||
})
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears a preserved v19 background hint before attaching its stream', async () => {
|
||||
const ensureConnectedSpy = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
.mockResolvedValue()
|
||||
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
|
||||
isNew: true,
|
||||
pid: null,
|
||||
shellState: 'unsupported',
|
||||
snapshot: null
|
||||
} as never)
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 })
|
||||
try {
|
||||
await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 })
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId: 'legacy-session',
|
||||
background: false
|
||||
})
|
||||
expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestSpy.mock.invocationCallOrder[0]
|
||||
)
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
notifySpy.mockRestore()
|
||||
requestSpy.mockRestore()
|
||||
ensureConnectedSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppliedSize', () => {
|
||||
it('reports the spawn dims before any resize', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
@@ -225,6 +359,23 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBufferSnapshot', () => {
|
||||
it('returns the daemon model with its absolute stream sequence', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
lastSubprocess._simulateData('complete hidden output\r\n')
|
||||
|
||||
const snapshot = await adapter.getBufferSnapshot(id, { scrollbackRows: 123 })
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
data: expect.stringContaining('complete hidden output'),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 'complete hidden output\r\n'.length,
|
||||
source: 'headless'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('kills the session', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
@@ -884,6 +1035,105 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
|
||||
|
||||
try {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const { id } = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: 'sleep-checkpoint'
|
||||
})
|
||||
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
|
||||
|
||||
lastSubprocess._simulateData('latest before sleep\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
|
||||
expect(checkpointSpy).toHaveBeenCalledWith(
|
||||
id,
|
||||
expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') })
|
||||
)
|
||||
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
|
||||
|
||||
const restored = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(restored.coldRestore?.scrollback).toContain('latest before sleep')
|
||||
historyAdapter.ackColdRestore(id)
|
||||
|
||||
const remountAfterAck = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(remountAfterAck.coldRestore).toBeUndefined()
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
})
|
||||
|
||||
it('cold restores the second sleep/wake cycle with post-wake output', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
|
||||
|
||||
try {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const { id } = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: 'sleep-wake-cycles'
|
||||
})
|
||||
|
||||
lastSubprocess._simulateData('first cycle content\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json')
|
||||
const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json')
|
||||
// Why: keep-history sleep stays unclean so cold restore remains eligible;
|
||||
// the final checkpoint is the deterministic handoff signal.
|
||||
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
|
||||
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
|
||||
'first cycle content'
|
||||
)
|
||||
|
||||
const firstWake = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(firstWake.coldRestore?.scrollback).toContain('first cycle content')
|
||||
historyAdapter.ackColdRestore(id)
|
||||
expect(historyAdapter.hasPty(id)).toBe(true)
|
||||
|
||||
lastSubprocess._simulateData('second cycle content\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
|
||||
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
|
||||
'second cycle content'
|
||||
)
|
||||
|
||||
const secondWake = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: id
|
||||
})
|
||||
expect(secondWake.coldRestore?.scrollback).toContain('second cycle content')
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
})
|
||||
|
||||
it('writes meta.json with endedAt on exit', async () => {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from './types'
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyBackgroundStreamEvent,
|
||||
PtyProviderBufferSnapshot,
|
||||
PtyProcessInfo,
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
@@ -79,8 +81,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// lock, each would fork its own daemon process. This promise coalesces
|
||||
// concurrent respawns so only the first caller forks; the rest await it.
|
||||
private respawnPromise: Promise<void> | null = null
|
||||
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
private dataListeners: ((payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
}) => void)[] = []
|
||||
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
|
||||
private removeEventListener: (() => void) | null = null
|
||||
private initialCwds = new Map<string, string>()
|
||||
// Why: React re-renders and StrictMode double-mounts can call createOrAttach
|
||||
@@ -93,6 +100,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// mount → ??? The sticky cache returns the same cold restore data on the
|
||||
// second mount until the renderer explicitly acknowledges it.
|
||||
private coldRestoreCache = new Map<string, ColdRestorePayload>()
|
||||
private sleepRestoreSessionIds = new Set<string>()
|
||||
private activeSessionIds = new Set<string>()
|
||||
private dirtySessionVersions = new Map<string, number>()
|
||||
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
|
||||
@@ -109,6 +117,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why: incremental checkpoints require the takePendingOutput RPC (v13+).
|
||||
// Against older daemons the tick falls back to full-snapshot checkpoints.
|
||||
private supportsIncrementalCheckpoints: boolean
|
||||
// Why: producer pause/resume notifications require v19+; legacy daemons
|
||||
// must never see them, so gating makes them silent no-ops there.
|
||||
private supportsProducerFlowControl: boolean
|
||||
private supportsAuthoritativeBufferSnapshots: boolean
|
||||
private pausedProducerSessionIds = new Set<string>()
|
||||
// Why tracked here: the daemon's background set (keep-tail stream thinning
|
||||
// + transient-fact scan authority) dies with the daemon process/socket;
|
||||
// re-sync it on a fresh connection so hidden panes stay thinned.
|
||||
private backgroundedSessionIds = new Set<string>()
|
||||
// Why: a daemon that survives a socket drop can still hold a pause whose
|
||||
// resume died with the connection. Owe those sessions a resume on the next
|
||||
// connect; the daemon's 5s failsafe covers the window in between.
|
||||
private producerResumesOwedOnReconnect = new Set<string>()
|
||||
private static CHECKPOINT_INTERVAL_MS = 5_000
|
||||
// Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB
|
||||
// snapshot checkpoint on every 5s tick via pending-buffer overflow or the
|
||||
@@ -133,6 +154,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.respawnFn = opts.respawn ?? null
|
||||
this.supportsCheckpoints = this.protocolVersion >= 4
|
||||
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
|
||||
this.supportsProducerFlowControl = this.protocolVersion >= 19
|
||||
this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20
|
||||
this.client.onDisconnected(() => {
|
||||
for (const id of this.pausedProducerSessionIds) {
|
||||
this.producerResumesOwedOnReconnect.add(id)
|
||||
}
|
||||
this.pausedProducerSessionIds.clear()
|
||||
})
|
||||
}
|
||||
|
||||
getHistoryManager(): HistoryManager | null {
|
||||
@@ -155,6 +184,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
await this.ensureConnected()
|
||||
// Why before createOrAttach: a preserved v19 daemon may remember this
|
||||
// session as backgrounded. Ordered control delivery clears it before any
|
||||
// newly attached stream bytes can be thinned without a recoverable seq.
|
||||
if (!this.supportsAuthoritativeBufferSnapshots) {
|
||||
this.setPtyBackgrounded(sessionId, false)
|
||||
}
|
||||
|
||||
// Why: detect crash-recovery history before spawning a replacement PTY so
|
||||
// the revived shell inherits the recovered cwd and dimensions instead of
|
||||
@@ -232,6 +267,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// but should still return the cached cold restore data.
|
||||
const cachedRestore = this.coldRestoreCache.get(sessionId)
|
||||
if (cachedRestore) {
|
||||
// Why: wake after sleep also lands here, and the slept session's active
|
||||
// tracking and history writer were dropped when sleep killed the PTY.
|
||||
// Without re-registering both, checkpoints stop after wake and the
|
||||
// second sleep/wake cycle restores a blank terminal.
|
||||
this.activeSessionIds.add(sessionId)
|
||||
if (this.historyManager) {
|
||||
this.historyManager.reopenSession(sessionId)
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
pid,
|
||||
@@ -275,13 +318,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// an unclean shutdown → return saved scrollback so the renderer can
|
||||
// display the previous terminal content.
|
||||
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
|
||||
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
|
||||
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
|
||||
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
|
||||
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
|
||||
// (no rehydrateSequences — they start with \x1b[?1049h, which the
|
||||
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
|
||||
// normal scrollback. An empty snapshot still yields null → no-op.
|
||||
const coldRestore = this.buildColdRestorePayload(restoreInfo)
|
||||
const canReanchorHistory = !scrollback || result.historySeeded === true
|
||||
// Why: use registerWriter (not openSession) to avoid deleting the
|
||||
// existing checkpoint.json. If the revived daemon crashes again before
|
||||
@@ -300,8 +337,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.historyManager.suspendSession(sessionId)
|
||||
}
|
||||
}
|
||||
if (scrollback) {
|
||||
const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(sessionId, coldRestore)
|
||||
return {
|
||||
id: sessionId,
|
||||
@@ -348,13 +384,24 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
const isAltScreen = result.snapshot.modes.alternateScreen
|
||||
const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi
|
||||
const snapshotPayload =
|
||||
result.snapshot.scrollbackAnsi +
|
||||
result.snapshot.rehydrateSequences +
|
||||
result.snapshot.snapshotAnsi
|
||||
// Why kitty flags ride beside the payload, not inside it: the snapshot
|
||||
// string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's
|
||||
// deliberate kitty reset must win. Only the runtime emulator re-seed
|
||||
// consumes the flags (terminal-query-authority.md §kitty).
|
||||
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
|
||||
return {
|
||||
id: sessionId,
|
||||
pid,
|
||||
snapshot: snapshotPayload,
|
||||
snapshotCols: result.snapshot.cols,
|
||||
snapshotRows: result.snapshot.rows,
|
||||
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
|
||||
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
|
||||
: {}),
|
||||
isReattach: true,
|
||||
isAlternateScreen: isAltScreen,
|
||||
// Why: carry the mid-escape tail so the renderer can write it after the
|
||||
@@ -368,6 +415,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
async attach(id: string): Promise<void> {
|
||||
await this.ensureConnected()
|
||||
if (!this.supportsAuthoritativeBufferSnapshots) {
|
||||
this.setPtyBackgrounded(id, false)
|
||||
}
|
||||
|
||||
await this.client.request<CreateOrAttachResult>('createOrAttach', {
|
||||
sessionId: id,
|
||||
@@ -390,16 +440,62 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.client.notify('resize', { sessionId: id, cols, rows })
|
||||
}
|
||||
|
||||
pauseProducer(id: string): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.add(id)
|
||||
this.client.notify('pausePty', { sessionId: id })
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.producerResumesOwedOnReconnect.delete(id)
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.delete(id)
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
|
||||
// Why fire-and-forget (like pausePty): a delivery hint for the daemon's
|
||||
// keep-tail stream thinning.
|
||||
setPtyBackgrounded(id: string, background: boolean): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
// Why: preserved v19 daemons can thin but cannot return the absolute
|
||||
// snapshot sequence needed to recover a gap. Clear their stale hint too.
|
||||
const safeBackground = this.supportsAuthoritativeBufferSnapshots && background
|
||||
if (safeBackground) {
|
||||
this.backgroundedSessionIds.add(id)
|
||||
} else {
|
||||
this.backgroundedSessionIds.delete(id)
|
||||
}
|
||||
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
|
||||
}
|
||||
|
||||
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
// Why: sleep/exact-stop must preserve restorable terminal history,
|
||||
// so force a final checkpoint before killing the daemon session.
|
||||
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
|
||||
// Force a final snapshot so wake can restore the pane users left.
|
||||
if (opts.keepHistory) {
|
||||
if (this.checkpointInFlight) {
|
||||
await this.checkpointInFlight
|
||||
}
|
||||
await this.checkpointSessions([id], { final: true, teardown: true })
|
||||
const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null
|
||||
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(id, coldRestore)
|
||||
this.sleepRestoreSessionIds.add(id)
|
||||
}
|
||||
}
|
||||
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
|
||||
this.activeSessionIds.delete(id)
|
||||
this.dirtySessionVersions.delete(id)
|
||||
this.coldRestoreCache.delete(id)
|
||||
if (!opts.keepHistory) {
|
||||
this.coldRestoreCache.delete(id)
|
||||
this.sleepRestoreSessionIds.delete(id)
|
||||
}
|
||||
// Why: the !keepHistory close path doesn't take a final checkpoint, so a
|
||||
// session stranded in sessionsNeedingFullCheckpoint would never be cleared.
|
||||
// (Under keepHistory the final checkpoint above already cleared the flag, so
|
||||
@@ -435,12 +531,30 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
ackColdRestore(sessionId: string): void {
|
||||
this.coldRestoreCache.delete(sessionId)
|
||||
this.sleepRestoreSessionIds.delete(sessionId)
|
||||
}
|
||||
|
||||
clearTombstone(sessionId: string): void {
|
||||
this.killedSessionTombstones.delete(sessionId)
|
||||
}
|
||||
|
||||
private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
|
||||
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
|
||||
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
|
||||
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
|
||||
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
|
||||
// (no rehydrateSequences — they start with \x1b[?1049h, which the
|
||||
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
|
||||
// normal scrollback. An empty snapshot still yields null → no-op.
|
||||
const scrollback = restoreInfo.modes.alternateScreen
|
||||
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
|
||||
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
|
||||
if (!scrollback) {
|
||||
return null
|
||||
}
|
||||
return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
|
||||
}
|
||||
|
||||
async sendSignal(id: string, signal: string): Promise<void> {
|
||||
await this.client.request('signal', { sessionId: id, signal })
|
||||
}
|
||||
@@ -478,6 +592,44 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async getBufferSnapshot(
|
||||
id: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
if (!this.supportsAuthoritativeBufferSnapshots) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
|
||||
sessionId: id,
|
||||
...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {})
|
||||
})
|
||||
const snapshot = result.snapshot
|
||||
// Why: older v19 daemons have no absolute output sequence. Their snapshot
|
||||
// cannot safely reconcile stream bytes still queued on the other socket.
|
||||
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
cwd: snapshot.cwd,
|
||||
lastTitle: snapshot.lastTitle,
|
||||
seq: snapshot.outputSequence,
|
||||
source: 'headless',
|
||||
oscLinks: snapshot.oscLinks,
|
||||
alternateScreen: snapshot.modes.alternateScreen,
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async clearBuffer(id: string): Promise<void> {
|
||||
await this.client.request('clearScrollback', { sessionId: id })
|
||||
this.markSessionDirty(id)
|
||||
@@ -608,6 +760,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.dirtySessionVersions.clear()
|
||||
this.lastFullCheckpointAt.clear()
|
||||
this.sessionsNeedingFullCheckpoint.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.stopCheckpointTimer()
|
||||
for (const id of ids) {
|
||||
this.coldRestoreCache.delete(id)
|
||||
@@ -640,7 +794,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s }))
|
||||
}
|
||||
|
||||
onData(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
onData(
|
||||
callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
|
||||
): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.dataListeners.indexOf(callback)
|
||||
@@ -650,6 +806,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
this.backgroundStreamListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.backgroundStreamListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.backgroundStreamListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
@@ -669,6 +835,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.dirtySessionVersions.clear()
|
||||
this.lastFullCheckpointAt.clear()
|
||||
this.coldRestoreCache.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
// Why: final checkpoints are written daemon-side in TerminalHost.dispose()
|
||||
@@ -705,6 +873,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.dirtySessionVersions.clear()
|
||||
this.lastFullCheckpointAt.clear()
|
||||
this.coldRestoreCache.clear()
|
||||
// Why: the detached daemon keeps these PTYs alive for warm reattach; a
|
||||
// pause left behind would block their shells for a failsafe window.
|
||||
for (const id of this.pausedProducerSessionIds) {
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
this.client.disconnect()
|
||||
@@ -712,8 +887,36 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
private async ensureConnected(): Promise<void> {
|
||||
await this.client.ensureConnected()
|
||||
// Why sampled before setupEventRouting: routing is (re)installed exactly
|
||||
// once per connection, so "no listener yet" identifies a fresh connect —
|
||||
// the only time the daemon-side backgrounded set needs a resync (it is
|
||||
// process state that died with the previous daemon/socket).
|
||||
const isFreshConnection = this.removeEventListener === null
|
||||
this.setupEventRouting()
|
||||
this.scheduleCheckpointTimer()
|
||||
this.flushOwedProducerResumes()
|
||||
if (isFreshConnection) {
|
||||
this.resyncBackgroundedSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private resyncBackgroundedSessions(): void {
|
||||
for (const id of this.backgroundedSessionIds) {
|
||||
// Harmless no-op for sessions the daemon doesn't know (yet).
|
||||
this.client.notify('setSessionBackground', { sessionId: id, background: true })
|
||||
}
|
||||
}
|
||||
|
||||
private flushOwedProducerResumes(): void {
|
||||
if (this.producerResumesOwedOnReconnect.size === 0) {
|
||||
return
|
||||
}
|
||||
for (const id of this.producerResumesOwedOnReconnect) {
|
||||
// Why: resuming a session the fresh daemon doesn't know is a harmless
|
||||
// no-op; leaving a survivor paused would waste 5s of failsafe latency.
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
}
|
||||
|
||||
private stopCheckpointTimer(): void {
|
||||
@@ -1027,6 +1230,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void {
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.backgroundStreamListeners]) {
|
||||
listener(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise<void> {
|
||||
console.warn(message)
|
||||
this.removeEventListener?.()
|
||||
@@ -1050,12 +1260,50 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.markSessionDirty(event.sessionId)
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.dataListeners]) {
|
||||
listener({ id: event.sessionId, data: event.payload.data })
|
||||
listener({
|
||||
id: event.sessionId,
|
||||
data: event.payload.data,
|
||||
...(event.payload.sequenceChars === undefined
|
||||
? {}
|
||||
: { sequenceChars: event.payload.sequenceChars })
|
||||
})
|
||||
}
|
||||
} else if (event.event === 'sessionBackgroundMarker') {
|
||||
this.emitBackgroundStreamEvent({
|
||||
id: event.sessionId,
|
||||
kind: 'backgroundMarker',
|
||||
background: event.payload.background,
|
||||
...(event.payload.scanSeedAnsi !== undefined
|
||||
? { scanSeedAnsi: event.payload.scanSeedAnsi }
|
||||
: {})
|
||||
})
|
||||
} else if (event.event === 'dataGap') {
|
||||
this.emitBackgroundStreamEvent({
|
||||
id: event.sessionId,
|
||||
kind: 'dataGap',
|
||||
droppedChars: event.payload.droppedChars,
|
||||
...(event.payload.sequenceChars === undefined
|
||||
? {}
|
||||
: { sequenceChars: event.payload.sequenceChars })
|
||||
})
|
||||
} else if (event.event === 'transientFact') {
|
||||
this.emitBackgroundStreamEvent({
|
||||
id: event.sessionId,
|
||||
kind: 'transientFact',
|
||||
fact: event.payload
|
||||
})
|
||||
} else if (event.event === 'exit') {
|
||||
this.activeSessionIds.delete(event.sessionId)
|
||||
this.dirtySessionVersions.delete(event.sessionId)
|
||||
this.coldRestoreCache.delete(event.sessionId)
|
||||
// Why: an exited session must not be owed a resume on reconnect — a
|
||||
// reused sessionId would receive a stray resumePty. Same for the
|
||||
// background set: a reused id must start un-thinned.
|
||||
this.pausedProducerSessionIds.delete(event.sessionId)
|
||||
this.producerResumesOwedOnReconnect.delete(event.sessionId)
|
||||
this.backgroundedSessionIds.delete(event.sessionId)
|
||||
if (!this.sleepRestoreSessionIds.has(event.sessionId)) {
|
||||
this.coldRestoreCache.delete(event.sessionId)
|
||||
}
|
||||
// Why: an exited session can never be checkpointed again, so its pending
|
||||
// full-checkpoint flag is dead state. Without this, a cold-restored
|
||||
// session that exits before its first checkpoint leaks a permanent entry.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
|
||||
type AdapterMock = DaemonPtyAdapter & {
|
||||
emitData: (id: string, data: string) => void
|
||||
emitData: (id: string, data: string, sequenceChars?: number) => void
|
||||
emitBackground: (event: PtyBackgroundStreamEvent) => void
|
||||
emitExit: (id: string, code: number) => void
|
||||
}
|
||||
|
||||
@@ -24,7 +25,9 @@ function createAdapter(
|
||||
reconcileResult?: { alive: string[]; killed: string[] }
|
||||
): AdapterMock {
|
||||
const writes: { id: string; data: string }[] = []
|
||||
const dataListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
|
||||
[]
|
||||
const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
|
||||
const exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
return {
|
||||
spawn: vi.fn(async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
|
||||
@@ -44,6 +47,8 @@ function createAdapter(
|
||||
writes.push({ id, data })
|
||||
}),
|
||||
resize: vi.fn(),
|
||||
setPtyBackgrounded: vi.fn(),
|
||||
getBufferSnapshot: vi.fn(async () => null),
|
||||
shutdown: vi.fn(async (id: string) => {
|
||||
const idx = sessions.indexOf(id)
|
||||
if (idx !== -1) {
|
||||
@@ -62,12 +67,23 @@ function createAdapter(
|
||||
revive: vi.fn(async () => {}),
|
||||
getDefaultShell: vi.fn(async () => '/bin/zsh'),
|
||||
getProfiles: vi.fn(async () => []),
|
||||
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
|
||||
dataListeners.push(callback)
|
||||
onData: vi.fn(
|
||||
(callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => {
|
||||
dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = dataListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
dataListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => {
|
||||
backgroundListeners.push(callback)
|
||||
return () => {
|
||||
const idx = dataListeners.indexOf(callback)
|
||||
const idx = backgroundListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
dataListeners.splice(idx, 1)
|
||||
backgroundListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -85,9 +101,14 @@ function createAdapter(
|
||||
reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }),
|
||||
dispose: vi.fn(),
|
||||
disconnectOnly: vi.fn(async () => {}),
|
||||
emitData: (id: string, data: string) => {
|
||||
emitData: (id: string, data: string, sequenceChars?: number) => {
|
||||
for (const listener of dataListeners) {
|
||||
listener({ id, data })
|
||||
listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) })
|
||||
}
|
||||
},
|
||||
emitBackground: (event: PtyBackgroundStreamEvent) => {
|
||||
for (const listener of backgroundListeners) {
|
||||
listener(event)
|
||||
}
|
||||
},
|
||||
emitExit: (id: string, code: number) => {
|
||||
@@ -118,6 +139,62 @@ describe('DaemonPtyRouter', () => {
|
||||
expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n')
|
||||
})
|
||||
|
||||
it('routes background hints and authoritative snapshots to the session owner', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
const snapshot = {
|
||||
data: 'legacy frame',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 42,
|
||||
source: 'headless' as const
|
||||
}
|
||||
vi.mocked(legacy.getBufferSnapshot).mockResolvedValue(snapshot)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
router.setPtyBackgrounded('legacy-session', true)
|
||||
await expect(
|
||||
router.getBufferSnapshot('legacy-session', { scrollbackRows: 50_000 })
|
||||
).resolves.toEqual(snapshot)
|
||||
|
||||
expect(legacy.setPtyBackgrounded).toHaveBeenCalledWith('legacy-session', true)
|
||||
expect(current.setPtyBackgrounded).not.toHaveBeenCalled()
|
||||
expect(legacy.getBufferSnapshot).toHaveBeenCalledWith('legacy-session', {
|
||||
scrollbackRows: 50_000
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards gap events and explicit sequence accounting from every adapter', () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy')
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
const dataSpy = vi.fn()
|
||||
const backgroundSpy = vi.fn()
|
||||
router.onData(dataSpy)
|
||||
router.onBackgroundStreamEvent(backgroundSpy)
|
||||
|
||||
current.emitData('current-session', '\x1b[6n', 0)
|
||||
legacy.emitBackground({
|
||||
id: 'legacy-session',
|
||||
kind: 'dataGap',
|
||||
droppedChars: 512,
|
||||
sequenceChars: 508
|
||||
})
|
||||
|
||||
expect(dataSpy).toHaveBeenCalledWith({
|
||||
id: 'current-session',
|
||||
data: '\x1b[6n',
|
||||
sequenceChars: 0
|
||||
})
|
||||
expect(backgroundSpy).toHaveBeenCalledWith({
|
||||
id: 'legacy-session',
|
||||
kind: 'dataGap',
|
||||
droppedChars: 512,
|
||||
sequenceChars: 508
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a legacy mapping after the routed session exits', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyBackgroundStreamEvent,
|
||||
PtyProviderBufferSnapshot,
|
||||
PtyProcessInfo,
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
@@ -11,7 +13,11 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
private legacy: DaemonPtyAdapter[]
|
||||
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
private dataListeners: ((payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
}) => void)[] = []
|
||||
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
|
||||
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
||||
@@ -76,6 +82,18 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
this.adapterFor(id).resize(id, cols, rows)
|
||||
}
|
||||
|
||||
pauseProducer(id: string): void {
|
||||
this.adapterFor(id).pauseProducer(id)
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.adapterFor(id).resumeProducer(id)
|
||||
}
|
||||
|
||||
setPtyBackgrounded(id: string, background: boolean): void {
|
||||
this.adapterFor(id).setPtyBackgrounded(id, background)
|
||||
}
|
||||
|
||||
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
await this.adapterFor(id).shutdown(id, opts)
|
||||
// Why: sleep passes keepHistory=true and re-spawns against the same
|
||||
@@ -105,6 +123,13 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return (await this.adapterFor(id).getAppliedSize?.(id)) ?? null
|
||||
}
|
||||
|
||||
async getBufferSnapshot(
|
||||
id: string,
|
||||
opts?: { scrollbackRows?: number }
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
return await this.adapterFor(id).getBufferSnapshot(id, opts)
|
||||
}
|
||||
|
||||
async clearBuffer(id: string): Promise<void> {
|
||||
await this.adapterFor(id).clearBuffer(id)
|
||||
}
|
||||
@@ -144,7 +169,9 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return this.current.getProfiles()
|
||||
}
|
||||
|
||||
onData(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
onData(
|
||||
callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
|
||||
): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.dataListeners.indexOf(callback)
|
||||
@@ -154,6 +181,17 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
const unsubscribes = this.allAdapters().map((adapter) =>
|
||||
adapter.onBackgroundStreamEvent(callback)
|
||||
)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -408,6 +408,72 @@ describe('DaemonServer', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps exit behind final output held by the shallow socket gate', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let subprocess: ReturnType<typeof createMockSubprocess>
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => {
|
||||
subprocess = createMockSubprocess()
|
||||
return subprocess
|
||||
}
|
||||
})
|
||||
const daemon = server as unknown as DaemonServerPrivate
|
||||
const refillCallbacks: (() => void)[] = []
|
||||
const controlSocket = { destroy: vi.fn() } as unknown as Socket
|
||||
const streamSocket = {
|
||||
destroyed: false,
|
||||
destroy: vi.fn(),
|
||||
writableLength: 128 * 1024,
|
||||
write: vi.fn((_line: string, callback?: () => void) => {
|
||||
if (callback) {
|
||||
refillCallbacks.push(callback)
|
||||
}
|
||||
return true
|
||||
})
|
||||
} as unknown as Socket & {
|
||||
write: ReturnType<typeof vi.fn>
|
||||
writableLength: number
|
||||
}
|
||||
|
||||
daemon.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket,
|
||||
streamSocket
|
||||
})
|
||||
await daemon.routeRequest('client-1', {
|
||||
id: 'req-1',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
|
||||
})
|
||||
|
||||
const finalOutput = 'final-output'.repeat(1024)
|
||||
subprocess!._simulateData(finalOutput)
|
||||
subprocess!._simulateExit(42)
|
||||
|
||||
// Only the refill sentinel may enter the already-deep socket; exit
|
||||
// remains queued behind the final data for this session.
|
||||
expect(refillCallbacks).toHaveLength(1)
|
||||
const beforeRefill = streamSocket.write.mock.calls.map(([line]) => JSON.parse(String(line)))
|
||||
expect(beforeRefill).toHaveLength(1)
|
||||
expect(beforeRefill[0]).toMatchObject({ event: 'data', payload: { data: '' } })
|
||||
|
||||
streamSocket.writableLength = 0
|
||||
refillCallbacks[0]()
|
||||
const delivered = streamSocket.write.mock.calls
|
||||
.map(
|
||||
([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } }
|
||||
)
|
||||
.filter((message) => message.payload.data !== '')
|
||||
expect(delivered.map((message) => message.event)).toEqual(['data', 'exit'])
|
||||
expect(delivered[0]?.payload.data).toBe(finalOutput)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('authentication', () => {
|
||||
|
||||
@@ -9,6 +9,15 @@ import { StringDecoder } from 'node:string_decoder'
|
||||
import { encodeNdjson, createNdjsonParser } from './ndjson'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import {
|
||||
BackgroundTransientFactRelay,
|
||||
BACKGROUND_STREAM_DROP_ENABLED
|
||||
} from './daemon-background-transient-facts'
|
||||
import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction'
|
||||
import {
|
||||
recordDaemonStreamBacklogEvent,
|
||||
startDaemonStreamBacklogProbe
|
||||
} from './daemon-stream-backlog-probe'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { checkPtySpawnHealth } from './pty-subprocess'
|
||||
@@ -53,8 +62,40 @@ export class DaemonServer {
|
||||
private log: DaemonFileLog
|
||||
|
||||
private clients = new Map<string, ConnectedClient>()
|
||||
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
|
||||
private streamDataBatcher = new DaemonStreamDataBatcher(
|
||||
(clientId) => this.clients.get(clientId),
|
||||
{
|
||||
isSessionDroppable: (sessionId) =>
|
||||
BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId),
|
||||
salvageDroppedData: (dropped) => {
|
||||
if (!dropped.includes('\x1b')) {
|
||||
return ''
|
||||
}
|
||||
const extracted = extractHiddenStartupRendererQueryData(dropped, '')
|
||||
return (
|
||||
extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
// Fact scan authority for backgrounded sessions — facts ride the stream
|
||||
// queue as control entries so they hold byte order with the data around
|
||||
// them (a fact jumping the queue could arrive after the reveal snapshot
|
||||
// that already reflects it).
|
||||
private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => {
|
||||
const clientId = this.streamClientIdBySessionId.get(sessionId)
|
||||
if (clientId) {
|
||||
this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'transientFact',
|
||||
sessionId,
|
||||
payload: fact
|
||||
})
|
||||
}
|
||||
})
|
||||
private streamClientIdBySessionId = new Map<string, string>()
|
||||
private lastInputAtBySessionId = new Map<string, number>()
|
||||
private stopStreamBacklogProbe: () => void = () => {}
|
||||
|
||||
// Why: main-process PTY IPC has the same recent-input bypass, but daemon
|
||||
// output reaches main only after this stream layer. Keeping the window here
|
||||
@@ -69,6 +110,14 @@ export class DaemonServer {
|
||||
this.token = randomUUID()
|
||||
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({
|
||||
clients: Array.from(this.clients.values(), (client) => ({
|
||||
clientId: client.clientId,
|
||||
socketBufferedBytes: client.streamSocket?.writableLength ?? 0,
|
||||
batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId)
|
||||
})),
|
||||
backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes()
|
||||
}))
|
||||
this.log = opts.log ?? createNoopDaemonFileLog()
|
||||
}
|
||||
|
||||
@@ -97,6 +146,8 @@ export class DaemonServer {
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.stopStreamBacklogProbe()
|
||||
this.transientFactRelay.dispose()
|
||||
this.host.dispose()
|
||||
this.streamDataBatcher.clear()
|
||||
|
||||
@@ -225,6 +276,10 @@ export class DaemonServer {
|
||||
const previous = client.streamSocket
|
||||
socket.removeAllListeners('data')
|
||||
client.streamSocket = socket
|
||||
// Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk.
|
||||
socket.on('drain', () => {
|
||||
this.streamDataBatcher.flush(client.clientId)
|
||||
})
|
||||
|
||||
const cleanup = (): void => {
|
||||
socket.removeListener('close', cleanup)
|
||||
@@ -296,6 +351,9 @@ export class DaemonServer {
|
||||
: {}),
|
||||
streamClient: {
|
||||
onData: (data) => {
|
||||
// Scan BEFORE enqueue: the batcher may keep-tail drop this
|
||||
// chunk, but its facts must be captured regardless.
|
||||
this.transientFactRelay.onSessionData(p.sessionId, data)
|
||||
const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
|
||||
const isInteractiveOutput =
|
||||
data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
|
||||
@@ -307,24 +365,38 @@ export class DaemonServer {
|
||||
})
|
||||
},
|
||||
onExit: (code) => {
|
||||
// Why: exit tears down renderer handlers; flush final output first
|
||||
// so the last few milliseconds of PTY data are not stranded.
|
||||
// Why: exit tears down renderer handlers, so it must ride the
|
||||
// ordered queue behind final output even when the shallow socket
|
||||
// gate holds that output for a later drain pass.
|
||||
this.log.log('session-exited', { sessionId: p.sessionId, code })
|
||||
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId: p.sessionId,
|
||||
payload: { code }
|
||||
})
|
||||
this.streamDataBatcher.flush(clientId)
|
||||
recordDaemonStreamBacklogEvent('sessionExit', {
|
||||
sessionIdSuffix: p.sessionId.slice(-10)
|
||||
})
|
||||
this.transientFactRelay.onSessionExit(p.sessionId)
|
||||
this.streamClientIdBySessionId.delete(p.sessionId)
|
||||
this.lastInputAtBySessionId.delete(p.sessionId)
|
||||
if (client?.streamSocket) {
|
||||
client.streamSocket.write(
|
||||
encodeNdjson({
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId: p.sessionId,
|
||||
payload: { code }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
this.streamClientIdBySessionId.set(p.sessionId, clientId)
|
||||
// Why an attach-time marker: the adapter resyncs the background set on
|
||||
// a fresh connection, which can precede this attach — main's scan
|
||||
// suppression must still start at the head of the new stream.
|
||||
if (this.transientFactRelay.isBackgrounded(p.sessionId)) {
|
||||
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId: p.sessionId,
|
||||
payload: { background: true }
|
||||
})
|
||||
}
|
||||
this.log.log(result.isNew ? 'session-created' : 'session-attached', {
|
||||
sessionId: p.sessionId,
|
||||
pid: result.pid
|
||||
@@ -365,6 +437,56 @@ export class DaemonServer {
|
||||
}
|
||||
return {}
|
||||
|
||||
case 'pausePty':
|
||||
this.host.pauseProducer(request.payload.sessionId)
|
||||
return {}
|
||||
|
||||
case 'resumePty':
|
||||
this.host.resumeProducer(request.payload.sessionId)
|
||||
return {}
|
||||
|
||||
case 'setSessionBackground': {
|
||||
const sessionId = request.payload.sessionId
|
||||
const background = request.payload.background === true
|
||||
recordDaemonStreamBacklogEvent('setSessionBackground', {
|
||||
sessionIdSuffix: sessionId.slice(-10),
|
||||
background
|
||||
})
|
||||
if (!this.transientFactRelay.setSessionBackground(sessionId, background)) {
|
||||
return {}
|
||||
}
|
||||
if (background) {
|
||||
// Prime the fresh relay tracker with the emulator's dangling
|
||||
// incomplete escape so a sequence split across the handoff parses
|
||||
// exactly as if the relay had seen the whole stream.
|
||||
this.transientFactRelay.seedSessionScanState(
|
||||
sessionId,
|
||||
this.host.getPartialEscapeTailAnsi(sessionId)
|
||||
)
|
||||
}
|
||||
const streamClientId = this.streamClientIdBySessionId.get(sessionId)
|
||||
if (!streamClientId) {
|
||||
// Not attached yet — the attach-time marker covers the handoff.
|
||||
return {}
|
||||
}
|
||||
// Reveal deliberately does NOT discard or force-flush the queued
|
||||
// tail: main's model (hidden-output recovery buffer, tail previews)
|
||||
// needs those bytes — a finished program's last output lives there —
|
||||
// and the normal flush/drain loop delivers them within milliseconds
|
||||
// (bounded ≤ the keep-tail drop cap), in order, ahead of the marker.
|
||||
const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId)
|
||||
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId,
|
||||
payload: {
|
||||
background,
|
||||
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {})
|
||||
}
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'kill':
|
||||
this.lastInputAtBySessionId.delete(request.payload.sessionId)
|
||||
this.log.log('session-killed', {
|
||||
@@ -397,8 +519,26 @@ export class DaemonServer {
|
||||
case 'listSessions':
|
||||
return { sessions: this.host.listSessions() }
|
||||
|
||||
case 'getSnapshot':
|
||||
return { snapshot: this.host.getSnapshot(request.payload.sessionId) }
|
||||
case 'getSnapshot': {
|
||||
const snapshotStart = performance.now()
|
||||
const requestedScrollbackRows = request.payload.scrollbackRows
|
||||
const scrollbackRows =
|
||||
typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows)
|
||||
? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows)))
|
||||
: undefined
|
||||
const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows })
|
||||
const snapshotMs = performance.now() - snapshotStart
|
||||
if (snapshotMs >= 25) {
|
||||
// Serialize stalls block the daemon's single thread — every pty's
|
||||
// echo included. Surfaced here so multi-second typing stalls can be
|
||||
// attributed to checkpoint storms (issue #5096 family) in the field.
|
||||
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
|
||||
sessionIdSuffix: request.payload.sessionId.slice(-10),
|
||||
snapshotMs: Math.round(snapshotMs)
|
||||
})
|
||||
}
|
||||
return { snapshot }
|
||||
}
|
||||
|
||||
case 'getSize':
|
||||
return { size: this.host.getAppliedSize(request.payload.sessionId) }
|
||||
@@ -449,13 +589,12 @@ export class DaemonServer {
|
||||
// Why: write/resize are notification-heavy and intentionally do not wait
|
||||
// for replies. If their target session is gone, this synthetic exit is the
|
||||
// only signal the renderer gets to clear stale terminal pane bindings.
|
||||
client.streamSocket.write(
|
||||
encodeNdjson({
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId,
|
||||
payload: { code }
|
||||
})
|
||||
)
|
||||
this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId,
|
||||
payload: { code }
|
||||
})
|
||||
this.streamDataBatcher.flush(client.clientId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Env-gated diagnostics for the daemon→main stream backlog: samples the
|
||||
* batcher queue and each stream socket's user-space write buffer so
|
||||
* multi-second echo lag can be attributed to the hop that actually holds the
|
||||
* bytes. Enable by setting ORCA_DAEMON_STREAM_BACKLOG_FILE to a writable
|
||||
* path; zero cost otherwise. The timer only observes and appends JSONL — it
|
||||
* never mutates delivery state.
|
||||
*/
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
export type StreamBacklogClientSample = {
|
||||
clientId: string
|
||||
socketBufferedBytes: number
|
||||
batcherQueuedChars: number
|
||||
}
|
||||
|
||||
export type StreamBacklogSample = {
|
||||
clients: StreamBacklogClientSample[]
|
||||
backgroundedSessionIdSuffixes?: string[]
|
||||
}
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 250
|
||||
|
||||
/** Event-level entries interleaved with the periodic samples — used to
|
||||
* attribute WHO mutated pacing state, not just when counts changed. */
|
||||
export function recordDaemonStreamBacklogEvent(
|
||||
event: string,
|
||||
detail: Record<string, unknown>
|
||||
): void {
|
||||
const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), event, ...detail })}\n`)
|
||||
} catch {
|
||||
// Diagnostics must never break the daemon.
|
||||
}
|
||||
}
|
||||
|
||||
export function startDaemonStreamBacklogProbe(sample: () => StreamBacklogSample): () => void {
|
||||
const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE
|
||||
if (!filePath) {
|
||||
return () => {}
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), ...sample() })}\n`)
|
||||
} catch {
|
||||
// Diagnostics must never break the daemon.
|
||||
}
|
||||
}, SAMPLE_INTERVAL_MS)
|
||||
timer.unref?.()
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
@@ -6,12 +6,37 @@ import { createNdjsonParser } from './ndjson'
|
||||
function createBatcher(options?: ConstructorParameters<typeof DaemonStreamDataBatcher>[1]) {
|
||||
const streamSocket = {
|
||||
destroyed: false,
|
||||
writableLength: 0,
|
||||
write: vi.fn()
|
||||
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
|
||||
} as unknown as Socket & { write: ReturnType<typeof vi.fn>; writableLength: number }
|
||||
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }), options)
|
||||
return { batcher, streamSocket }
|
||||
}
|
||||
|
||||
function writtenData(streamSocket: { write: ReturnType<typeof vi.fn> }): string {
|
||||
return streamSocket.write.mock.calls
|
||||
.map(([line]) => {
|
||||
const parsed = JSON.parse(String(line)) as { payload?: { data?: string } }
|
||||
return parsed.payload?.data ?? ''
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
type ParsedWrite = {
|
||||
event: string
|
||||
sessionId?: string
|
||||
payload?: { data?: string }
|
||||
}
|
||||
|
||||
// A held pass arms a zero-payload data event whose kernel-flush callback
|
||||
// refills the queue; it carries no content, so assertions about delivered
|
||||
// output must ignore it.
|
||||
function nonSentinelWrites(streamSocket: { write: ReturnType<typeof vi.fn> }): ParsedWrite[] {
|
||||
return streamSocket.write.mock.calls
|
||||
.map(([line]) => JSON.parse(String(line)) as ParsedWrite)
|
||||
.filter((message) => !(message.event === 'data' && (message.payload?.data ?? '') === ''))
|
||||
}
|
||||
|
||||
describe('DaemonStreamDataBatcher', () => {
|
||||
it('coalesces background output before writing daemon stream events', () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -22,7 +47,7 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
batcher.enqueue('client-1', 'session-1', 'b')
|
||||
|
||||
expect(streamSocket.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(7)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(streamSocket.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
@@ -45,7 +70,7 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
|
||||
expect(streamSocket.write).toHaveBeenCalledTimes(1)
|
||||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('\\u001b[20;2Hredraw')
|
||||
vi.advanceTimersByTime(8)
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(streamSocket.write).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -65,7 +90,7 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
})
|
||||
|
||||
expect(streamSocket.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(8)
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(streamSocket.write).toHaveBeenCalledTimes(1)
|
||||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain(`${pending}redraw`)
|
||||
} finally {
|
||||
@@ -91,7 +116,7 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
)
|
||||
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"echo"')
|
||||
|
||||
vi.advanceTimersByTime(8)
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(streamSocket.write).toHaveBeenCalledTimes(2)
|
||||
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain(
|
||||
'"sessionId":"session-background"'
|
||||
@@ -102,6 +127,447 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('holds bulk output while the socket buffer is deep and resumes on the next flush', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
const bulk = 'x'.repeat(64 * 1024)
|
||||
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bulk', bulk)
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(nonSentinelWrites(streamSocket)).toHaveLength(0)
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(bulk.length)
|
||||
|
||||
// Socket drained (server routes 'drain' to flush): held bulk writes.
|
||||
streamSocket.writableLength = 0
|
||||
batcher.flush('client-1')
|
||||
expect(writtenData(streamSocket)).toBe(bulk)
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets interactive echo jump bulk held behind a deep socket', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024))
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(nonSentinelWrites(streamSocket)).toHaveLength(0)
|
||||
|
||||
batcher.enqueue('client-1', 'session-typing', 'echo', {
|
||||
flushImmediately: true,
|
||||
flushMaxChars: 1024
|
||||
})
|
||||
const written = nonSentinelWrites(streamSocket)
|
||||
expect(written).toHaveLength(1)
|
||||
expect(written[0]?.sessionId).toBe('session-typing')
|
||||
// The bulk stays held — order across sessions has no contract, and the
|
||||
// deep socket is exactly what the echo must not queue behind.
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(64 * 1024)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a small session write through the gate while a flooding session holds (per-session fairness)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-flood', 'x'.repeat(256 * 1024))
|
||||
// Non-interactive small output (echo that missed the 100ms fast-path).
|
||||
batcher.enqueue('client-1', 'session-typing', 'echo-line')
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
// The flood holds; the tiny session's bytes must NOT wait behind it.
|
||||
const written = nonSentinelWrites(streamSocket)
|
||||
expect(written.some((m) => m.sessionId === 'session-typing')).toBe(true)
|
||||
expect(writtenData(streamSocket)).toContain('echo-line')
|
||||
expect(written.some((m) => m.sessionId === 'session-flood')).toBe(false)
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(256 * 1024)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never reorders bytes within a session around the small-session bypass', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
// Same session: big entry then (non-adjacent) small entry.
|
||||
batcher.enqueue('client-1', 'session-a', 'x'.repeat(256 * 1024))
|
||||
batcher.enqueue('client-1', 'session-b', 'other')
|
||||
batcher.enqueue('client-1', 'session-a', 'tail')
|
||||
vi.advanceTimersByTime(2)
|
||||
const written = streamSocket.write.mock.calls.map(([line]) => String(line)).join('')
|
||||
// session-a held its first entry, so its tail must be held too.
|
||||
expect(written).not.toContain('tail')
|
||||
|
||||
streamSocket.writableLength = 0
|
||||
batcher.flush('client-1')
|
||||
expect(writtenData(streamSocket)).toContain('x'.repeat(64))
|
||||
// Full reassembly, in order, once drained.
|
||||
const aPayload = streamSocket.write.mock.calls
|
||||
.map(
|
||||
([line]) =>
|
||||
JSON.parse(String(line)) as { sessionId?: string; payload?: { data?: string } }
|
||||
)
|
||||
.filter((m) => m.sessionId === 'session-a')
|
||||
.map((m) => m.payload?.data ?? '')
|
||||
.join('')
|
||||
expect(aPayload).toBe(`${'x'.repeat(256 * 1024)}tail`)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('slices oversized held entries so one write cannot re-deepen the socket unboundedly', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
const bulk = 'y'.repeat(64 * 1024 + 5)
|
||||
|
||||
batcher.enqueue('client-1', 'session-bulk', bulk)
|
||||
vi.advanceTimersByTime(2)
|
||||
// Two slices: 64K then the 5-char remainder; payload reassembles intact.
|
||||
expect(streamSocket.write).toHaveBeenCalledTimes(2)
|
||||
expect(writtenData(streamSocket)).toBe(bulk)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops mid-queue when a written slice is followed by a still-deep socket', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
const first = 'a'.repeat(70 * 1024)
|
||||
|
||||
batcher.enqueue('client-1', 'session-bulk', first)
|
||||
// First slice write fills the socket past the gate; the remainder holds.
|
||||
streamSocket.write.mockImplementation(() => {
|
||||
streamSocket.writableLength = 200 * 1024
|
||||
return false
|
||||
})
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(nonSentinelWrites(streamSocket)).toHaveLength(1)
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(first.length - 64 * 1024)
|
||||
|
||||
streamSocket.writableLength = 0
|
||||
streamSocket.write.mockImplementation(() => true)
|
||||
batcher.flush('client-1')
|
||||
expect(writtenData(streamSocket)).toBe(first)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('writes through the gate once held bulk exceeds the memory safety valve', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
const huge = 'z'.repeat(32 * 1024 * 1024 + 1)
|
||||
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bulk', huge)
|
||||
vi.advanceTimersByTime(2)
|
||||
// Deep socket, but holding would exceed the valve: old write-through
|
||||
// behavior wins over bounded echo latency.
|
||||
expect(writtenData(streamSocket).length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not split surrogate pairs at the bulk slice boundary', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
// Position an astral char to straddle the 64K slice boundary.
|
||||
const bulk = `${'a'.repeat(64 * 1024 - 1)}😀${'b'.repeat(10)}`
|
||||
|
||||
batcher.enqueue('client-1', 'session-bulk', bulk)
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(writtenData(streamSocket)).toBe(bulk)
|
||||
for (const [line] of streamSocket.write.mock.calls) {
|
||||
expect(String(line)).not.toContain('�')
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keep-tail drops a droppable session over the cap and delivers a gap before the kept tail', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher({
|
||||
isSessionDroppable: (sessionId) => sessionId === 'session-bg'
|
||||
})
|
||||
// Two enqueues that together cross the 1MB cap.
|
||||
batcher.enqueue('client-1', 'session-bg', 'a'.repeat(900 * 1024))
|
||||
batcher.enqueue('client-1', 'session-bg', 'b'.repeat(300 * 1024))
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(512 * 1024)
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
const messages = streamSocket.write.mock.calls.map(
|
||||
([line]) =>
|
||||
JSON.parse(String(line)) as {
|
||||
event: string
|
||||
payload: { data?: string; droppedChars?: number }
|
||||
}
|
||||
)
|
||||
expect(messages[0]?.event).toBe('dataGap')
|
||||
expect(messages[0]?.payload.droppedChars).toBe((900 + 300 - 512) * 1024)
|
||||
const delivered = messages
|
||||
.filter((m) => m.event === 'data')
|
||||
.map((m) => m.payload.data ?? '')
|
||||
.join('')
|
||||
expect(delivered.length).toBe(512 * 1024)
|
||||
// Keep-TAIL: the newest bytes survive.
|
||||
expect(delivered.endsWith('b'.repeat(300 * 1024))).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('accounts every dropped char across repeated drops (gap sums + delivered = enqueued)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true })
|
||||
// Deep socket: data holds; a gap entry (~100B) may still write through —
|
||||
// it always precedes the kept tail, so a second drop mints a second gap.
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bg', 'a'.repeat(1024 * 1024 + 1))
|
||||
batcher.enqueue('client-1', 'session-bg', 'b'.repeat(600 * 1024))
|
||||
batcher.enqueue('client-1', 'session-bg', 'c'.repeat(600 * 1024))
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
streamSocket.writableLength = 0
|
||||
batcher.flush('client-1')
|
||||
const messages = streamSocket.write.mock.calls.map(
|
||||
([line]) =>
|
||||
JSON.parse(String(line)) as {
|
||||
event: string
|
||||
payload: { data?: string; droppedChars?: number }
|
||||
}
|
||||
)
|
||||
const gapChars = messages
|
||||
.filter((m) => m.event === 'dataGap')
|
||||
.reduce((sum, m) => sum + (m.payload.droppedChars ?? 0), 0)
|
||||
const dataMessages = messages.filter((m) => m.event === 'data')
|
||||
const deliveredChars = dataMessages.reduce((sum, m) => sum + (m.payload.data?.length ?? 0), 0)
|
||||
expect(deliveredChars).toBeLessThanOrEqual(1024 * 1024)
|
||||
expect(gapChars + deliveredChars).toBe(1024 * 1024 + 1 + 1200 * 1024)
|
||||
// The newest bytes always survive.
|
||||
expect(dataMessages.at(-1)?.payload.data?.endsWith('c')).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('salvages reply-eliciting query bytes out of dropped data', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const dsr = '\x1b[6n'
|
||||
const { batcher, streamSocket } = createBatcher({
|
||||
isSessionDroppable: () => true,
|
||||
salvageDroppedData: (dropped) => (dropped.includes(dsr) ? dsr : '')
|
||||
})
|
||||
// The DSR probe sits in the oldest (dropped) region.
|
||||
batcher.enqueue('client-1', 'session-bg', `flood${dsr}${'x'.repeat(900 * 1024)}`)
|
||||
batcher.enqueue('client-1', 'session-bg', 'y'.repeat(300 * 1024))
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
const messages = streamSocket.write.mock.calls.map(
|
||||
([line]) =>
|
||||
JSON.parse(String(line)) as {
|
||||
event: string
|
||||
payload: { data?: string; droppedChars?: number; sequenceChars?: number }
|
||||
}
|
||||
)
|
||||
expect(messages[0]?.event).toBe('dataGap')
|
||||
// The salvaged query rides right after the gap, before the kept tail.
|
||||
expect(messages[1]?.event).toBe('data')
|
||||
expect(messages[1]?.payload.data).toBe(dsr)
|
||||
expect(messages[1]?.payload.sequenceChars).toBe(0)
|
||||
const originalSequenceChars = messages.reduce(
|
||||
(sum, message) =>
|
||||
sum +
|
||||
(message.event === 'dataGap'
|
||||
? (message.payload.sequenceChars ?? 0)
|
||||
: (message.payload.sequenceChars ?? message.payload.data?.length ?? 0)),
|
||||
0
|
||||
)
|
||||
expect(originalSequenceChars).toBe(`flood${dsr}${'x'.repeat(900 * 1024)}`.length + 300 * 1024)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shrinks keep-tails as more backgrounded sessions queue (global aggregate budget)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true })
|
||||
streamSocket.writableLength = 128 * 1024 // deep socket: queues accumulate
|
||||
// 17 backgrounded sessions × 768KB — each below the single-session cap,
|
||||
// but a reveal would have to drain the ~13MB aggregate (measured 2.5s
|
||||
// hidden-restore). The global budget thins each to ~2MB/17 ≈ 120KB.
|
||||
for (let s = 0; s < 17; s++) {
|
||||
batcher.enqueue('client-1', `session-${s}`, '#'.repeat(768 * 1024))
|
||||
}
|
||||
const totalQueued = batcher.queuedCharsForClient('client-1')
|
||||
expect(totalQueued).toBeLessThan(3 * 1024 * 1024)
|
||||
// Every session still keeps at least a full screen of newest tail.
|
||||
streamSocket.writableLength = 0
|
||||
batcher.flush('client-1')
|
||||
const perSession = new Map<string, number>()
|
||||
for (const m of nonSentinelWrites(streamSocket)) {
|
||||
if (m.event === 'data' && m.sessionId) {
|
||||
perSession.set(
|
||||
m.sessionId,
|
||||
(perSession.get(m.sessionId) ?? 0) + (m.payload?.data?.length ?? 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let s = 0; s < 17; s++) {
|
||||
expect(perSession.get(`session-${s}`) ?? 0).toBeGreaterThanOrEqual(64 * 1024)
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never drops sessions that are not droppable', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => false })
|
||||
const bulk = 'v'.repeat(2 * 1024 * 1024)
|
||||
batcher.enqueue('client-1', 'session-visible', bulk)
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(writtenData(streamSocket)).toBe(bulk)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('delivers control events in byte order with the session data around them', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
batcher.enqueue('client-1', 'session-1', 'before')
|
||||
batcher.enqueueControlEvent('client-1', 'session-1', {
|
||||
type: 'event',
|
||||
event: 'transientFact',
|
||||
sessionId: 'session-1',
|
||||
payload: { kind: 'bell' }
|
||||
})
|
||||
batcher.enqueue('client-1', 'session-1', 'after')
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
const messages = streamSocket.write.mock.calls.map(
|
||||
([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } }
|
||||
)
|
||||
expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data'])
|
||||
expect(messages[0]?.payload.data).toBe('before')
|
||||
expect(messages[2]?.payload.data).toBe('after')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("holds a control event behind its session's held bulk (order latch)", () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024))
|
||||
batcher.enqueueControlEvent('client-1', 'session-bulk', {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId: 'session-bulk',
|
||||
payload: { background: true }
|
||||
})
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(nonSentinelWrites(streamSocket)).toHaveLength(0)
|
||||
|
||||
streamSocket.writableLength = 0
|
||||
batcher.flush('client-1')
|
||||
expect(nonSentinelWrites(streamSocket).map((m) => m.event)).toEqual([
|
||||
'data',
|
||||
'sessionBackgroundMarker'
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('delivers a held queued tail (data + facts, in order) once the socket drains', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bg', 'x'.repeat(64 * 1024))
|
||||
batcher.enqueueControlEvent('client-1', 'session-bg', {
|
||||
type: 'event',
|
||||
event: 'transientFact',
|
||||
sessionId: 'session-bg',
|
||||
payload: { kind: 'bell' }
|
||||
})
|
||||
batcher.enqueue('client-1', 'session-bg', 'DONE')
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
// Reveal never discards: a finished program's last output must reach
|
||||
// main's model (restore reads it). The normal drain loop delivers it.
|
||||
streamSocket.writableLength = 0
|
||||
batcher.flush('client-1')
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(0)
|
||||
const messages = nonSentinelWrites(streamSocket)
|
||||
expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data'])
|
||||
expect(messages.at(-1)?.payload?.data).toBe('DONE')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('arms one kernel-flush refill sentinel per held pass and resumes without waiting for drain', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
const flushCallbacks: (() => void)[] = []
|
||||
streamSocket.write.mockImplementation((_line: string, cb?: () => void) => {
|
||||
if (cb) {
|
||||
flushCallbacks.push(cb)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024))
|
||||
vi.advanceTimersByTime(2)
|
||||
// Held pass → exactly one sentinel armed; further held passes don't stack.
|
||||
expect(flushCallbacks).toHaveLength(1)
|
||||
batcher.flush('client-1')
|
||||
expect(flushCallbacks).toHaveLength(1)
|
||||
|
||||
// Kernel flushed the in-flight bytes (socket now shallow): the sentinel
|
||||
// callback resumes the held bulk with no 'drain' event involved.
|
||||
streamSocket.writableLength = 0
|
||||
flushCallbacks[0]()
|
||||
expect(writtenData(streamSocket)).toBe('x'.repeat(64 * 1024))
|
||||
expect(batcher.queuedCharsForClient('client-1')).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('writes large stream data as parser-sized NDJSON events', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -113,7 +579,7 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
const parser = createNdjsonParser(onMessage, onError, { maxLineBytes })
|
||||
|
||||
batcher.enqueue('client-1', 'session-1', data)
|
||||
vi.advanceTimersByTime(8)
|
||||
vi.advanceTimersByTime(2)
|
||||
for (const [line] of streamSocket.write.mock.calls) {
|
||||
parser.feed(String(line))
|
||||
}
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import {
|
||||
clampToSafeSplitIndex,
|
||||
encodeStreamDataEvent,
|
||||
writeStreamDataEvents
|
||||
} from './daemon-stream-data-split'
|
||||
import {
|
||||
backgroundSessionDropCapChars,
|
||||
backgroundSessionKeepTailChars,
|
||||
dropOldestQueuedForSession,
|
||||
type PendingStreamDataBatch
|
||||
} from './daemon-stream-keep-tail-drop'
|
||||
import type { DaemonEvent } from './types'
|
||||
|
||||
type StreamDataClient = {
|
||||
streamSocket: Socket | null
|
||||
}
|
||||
|
||||
type PendingStreamDataBatch = {
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
queue: { sessionId: string; data: string }[]
|
||||
queuedChars: number
|
||||
}
|
||||
// Why 2ms: under continuous agent output every chunk waits an expected
|
||||
// half-window here AND again in main's PTY batch — at 8ms each that was
|
||||
// ~8ms of the measured ~19ms DSR-under-load latency. 2ms keeps burst
|
||||
// coalescing (~500 socket writes/s worst case, ~100B framing overhead per
|
||||
// write against MB/s payloads) while cutting the fixed latency tax 4x.
|
||||
const STREAM_DATA_BATCH_INTERVAL_MS = 2
|
||||
|
||||
// Why: match main-process PTY IPC batching to avoid adding latency while
|
||||
// removing daemon socket writes and JSON framing during bursty output.
|
||||
const STREAM_DATA_BATCH_INTERVAL_MS = 8
|
||||
// Why a shallow socket: the stream socket is one FIFO for every session, and
|
||||
// bytes already written can never be overtaken — a deep user-space buffer
|
||||
// buries a visible pane's keystroke echo behind bulk output for other panes
|
||||
// (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at
|
||||
// this depth and the remainder is HELD here, where the interactive
|
||||
// flushSession path can still jump it; socket 'drain' refills. Echo latency
|
||||
// is then bounded by the shallow depth, not by how much bulk is in flight.
|
||||
// 128KB must stay above the socket's ~16KB highWaterMark so a held state
|
||||
// implies a false write() and therefore a guaranteed 'drain' wake-up.
|
||||
// Kill switch: ORCA_DAEMON_SHALLOW_SOCKET_GATE=0 restores pre-gate unbounded
|
||||
// socket writes for field debugging and true fix-off A/B benches.
|
||||
const SHALLOW_SOCKET_WRITE_GATE_BYTES =
|
||||
process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024
|
||||
// Why sliced writes: enqueue coalesces per-session entries, so a held entry
|
||||
// can grow to megabytes; writing it whole would re-deepen the socket past the
|
||||
// gate in one call.
|
||||
const BULK_WRITE_SLICE_CHARS = 64 * 1024
|
||||
// Safety valve: if held bulk ever exceeds this, write through to the socket
|
||||
// (exactly the pre-gate behavior) — bounded daemon memory beats bounded echo
|
||||
// latency in the extreme. Must sit FAR above the pacer's pause watermark plus
|
||||
// its overshoot (observed ~5MB with 17 paused sessions' in-flight pty reads):
|
||||
// an engaged valve deepens the socket and buries interactive echo behind the
|
||||
// whole backlog (measured as bimodal ~2.4s key medians when this was 8MB).
|
||||
const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024
|
||||
// Why a small-session bypass: the hold is there to stop FLOODS from burying
|
||||
// everyone else; a session with only a few KB queued (keystroke echo, prompt
|
||||
// redraws, query replies) is never the flood and must not wait FIFO behind
|
||||
// other sessions' megabytes. The daemon's 100ms interactive fast-path is a
|
||||
// heuristic that misses under event-loop load (measured: echo classified
|
||||
// non-interactive rode the held queue for ~2.4s); this bypass is the
|
||||
// deterministic backstop. Worst socket over-deepening per flush is
|
||||
// sessions × this ≈ tens of KB.
|
||||
const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024
|
||||
|
||||
type EnqueueOptions = {
|
||||
flushImmediately?: boolean
|
||||
@@ -22,90 +66,25 @@ type EnqueueOptions = {
|
||||
|
||||
type DaemonStreamDataBatcherOptions = {
|
||||
maxLineBytes?: number
|
||||
}
|
||||
|
||||
function encodeStreamDataEvent(sessionId: string, data: string): string {
|
||||
return encodeNdjson({
|
||||
type: 'event',
|
||||
event: 'data',
|
||||
sessionId,
|
||||
payload: { data }
|
||||
})
|
||||
}
|
||||
|
||||
function streamDataEventLineBytes(sessionId: string, data: string): number {
|
||||
return Buffer.byteLength(encodeStreamDataEvent(sessionId, data), 'utf8')
|
||||
}
|
||||
|
||||
function isHighSurrogate(value: number): boolean {
|
||||
return value >= 0xd800 && value <= 0xdbff
|
||||
}
|
||||
|
||||
function isLowSurrogate(value: number): boolean {
|
||||
return value >= 0xdc00 && value <= 0xdfff
|
||||
}
|
||||
|
||||
function clampToSafeSplitIndex(value: string, start: number, end: number): number {
|
||||
if (end <= start || end >= value.length) {
|
||||
return end
|
||||
}
|
||||
const prev = value.charCodeAt(end - 1)
|
||||
const next = value.charCodeAt(end)
|
||||
return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end
|
||||
}
|
||||
|
||||
function nextSafeSplitIndex(value: string, start: number): number {
|
||||
const next = Math.min(value.length, start + 1)
|
||||
if (
|
||||
next < value.length &&
|
||||
isHighSurrogate(value.charCodeAt(start)) &&
|
||||
isLowSurrogate(value.charCodeAt(next))
|
||||
) {
|
||||
return next + 1
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function splitStreamDataForNdjson(sessionId: string, data: string, maxLineBytes: number): string[] {
|
||||
if (streamDataEventLineBytes(sessionId, data) <= maxLineBytes) {
|
||||
return [data]
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
let start = 0
|
||||
while (start < data.length) {
|
||||
let low = start + 1
|
||||
let high = data.length
|
||||
let best = start
|
||||
|
||||
while (low <= high) {
|
||||
const rawMid = Math.floor((low + high) / 2)
|
||||
const mid = clampToSafeSplitIndex(data, start, rawMid)
|
||||
if (mid <= start) {
|
||||
low = rawMid + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (streamDataEventLineBytes(sessionId, data.slice(start, mid)) <= maxLineBytes) {
|
||||
best = mid
|
||||
low = rawMid + 1
|
||||
} else {
|
||||
high = rawMid - 1
|
||||
}
|
||||
}
|
||||
|
||||
const end = best > start ? best : nextSafeSplitIndex(data, start)
|
||||
chunks.push(data.slice(start, end))
|
||||
start = end
|
||||
}
|
||||
|
||||
return chunks
|
||||
/** Fires after each stream-socket write — the only place backlog grows, so
|
||||
* the backlog pacer checks its watermark here. */
|
||||
onAfterSocketWrite?: () => void
|
||||
/** True for sessions whose queued output may be keep-tail dropped
|
||||
* (main-marked background sessions). */
|
||||
isSessionDroppable?: (sessionId: string) => boolean
|
||||
/** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC color probes) out
|
||||
* of dropped data — the hidden program blocks on the reply, so those few
|
||||
* bytes must still be delivered even when their flood is not. */
|
||||
salvageDroppedData?: (dropped: string) => string
|
||||
}
|
||||
|
||||
export class DaemonStreamDataBatcher {
|
||||
private pendingByClient = new Map<string, PendingStreamDataBatch>()
|
||||
private getClient: (clientId: string) => StreamDataClient | undefined
|
||||
private maxLineBytes: number
|
||||
private onAfterSocketWrite: (() => void) | undefined
|
||||
private isSessionDroppable: (sessionId: string) => boolean
|
||||
private salvageDroppedData: (dropped: string) => string
|
||||
|
||||
constructor(
|
||||
getClient: (clientId: string) => StreamDataClient | undefined,
|
||||
@@ -113,6 +92,9 @@ export class DaemonStreamDataBatcher {
|
||||
) {
|
||||
this.getClient = getClient
|
||||
this.maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES)
|
||||
this.onAfterSocketWrite = options.onAfterSocketWrite
|
||||
this.isSessionDroppable = options.isSessionDroppable ?? (() => false)
|
||||
this.salvageDroppedData = options.salvageDroppedData ?? (() => '')
|
||||
}
|
||||
|
||||
enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void {
|
||||
@@ -121,19 +103,45 @@ export class DaemonStreamDataBatcher {
|
||||
return
|
||||
}
|
||||
|
||||
let batch = this.pendingByClient.get(clientId)
|
||||
if (!batch) {
|
||||
batch = { timer: null, queue: [], queuedChars: 0 }
|
||||
this.pendingByClient.set(clientId, batch)
|
||||
}
|
||||
|
||||
const batch = this.getOrCreateBatch(clientId)
|
||||
const last = batch.queue.at(-1)
|
||||
if (last?.sessionId === sessionId) {
|
||||
// Never coalesce across a control entry — it marks a position in the
|
||||
// session's byte order.
|
||||
if (last?.sessionId === sessionId && !last.control) {
|
||||
last.data += data
|
||||
} else {
|
||||
batch.queue.push({ sessionId, data })
|
||||
}
|
||||
batch.queuedChars += data.length
|
||||
batch.queuedCharsBySession.set(
|
||||
sessionId,
|
||||
(batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length
|
||||
)
|
||||
|
||||
if (this.isSessionDroppable(sessionId)) {
|
||||
// Keep-tail scales down as more backgrounded sessions queue, bounding
|
||||
// the AGGREGATE a reveal must drain (see daemon-stream-keep-tail-drop).
|
||||
const droppableQueued = this.countDroppableSessionsWithQueuedData(batch)
|
||||
const dropCap = backgroundSessionDropCapChars(droppableQueued)
|
||||
const keepTail = backgroundSessionKeepTailChars(droppableQueued)
|
||||
if ((batch.queuedCharsBySession.get(sessionId) ?? 0) > dropCap) {
|
||||
dropOldestQueuedForSession(batch, sessionId, keepTail, this.salvageDroppedData)
|
||||
}
|
||||
if (droppableQueued > (batch.lastDroppableSessionCount ?? 0)) {
|
||||
// The shared budget tightened: re-trim sessions that already finished
|
||||
// producing — they never re-enter this path on their own.
|
||||
for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) {
|
||||
if (
|
||||
queued > dropCap &&
|
||||
queuedSessionId !== sessionId &&
|
||||
this.isSessionDroppable(queuedSessionId)
|
||||
) {
|
||||
dropOldestQueuedForSession(batch, queuedSessionId, keepTail, this.salvageDroppedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
batch.lastDroppableSessionCount = droppableQueued
|
||||
}
|
||||
|
||||
if (
|
||||
options.flushImmediately === true &&
|
||||
@@ -148,6 +156,43 @@ export class DaemonStreamDataBatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a pre-shaped stream event at the current position in the
|
||||
* session's byte order (scan handoff markers, gaps, transient facts). */
|
||||
enqueueControlEvent(clientId: string, sessionId: string, control: DaemonEvent): void {
|
||||
const client = this.getClient(clientId)
|
||||
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
||||
return
|
||||
}
|
||||
const batch = this.getOrCreateBatch(clientId)
|
||||
batch.queue.push({ sessionId, data: '', control })
|
||||
if (!batch.timer) {
|
||||
batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private countDroppableSessionsWithQueuedData(batch: PendingStreamDataBatch): number {
|
||||
let count = 0
|
||||
for (const [sessionId, queued] of batch.queuedCharsBySession) {
|
||||
if (queued > 0 && this.isSessionDroppable(sessionId)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private getOrCreateBatch(clientId: string): PendingStreamDataBatch {
|
||||
let batch = this.pendingByClient.get(clientId)
|
||||
if (!batch) {
|
||||
batch = { timer: null, queue: [], queuedChars: 0, queuedCharsBySession: new Map() }
|
||||
this.pendingByClient.set(clientId, batch)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
queuedCharsForClient(clientId: string): number {
|
||||
return this.pendingByClient.get(clientId)?.queuedChars ?? 0
|
||||
}
|
||||
|
||||
flush(clientId: string): void {
|
||||
const batch = this.pendingByClient.get(clientId)
|
||||
if (!batch) {
|
||||
@@ -158,16 +203,113 @@ export class DaemonStreamDataBatcher {
|
||||
clearTimeout(batch.timer)
|
||||
batch.timer = null
|
||||
}
|
||||
this.pendingByClient.delete(clientId)
|
||||
|
||||
const client = this.getClient(clientId)
|
||||
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
||||
// Same as pre-gate behavior: a vanished stream socket drops the batch —
|
||||
// the model owns the bytes and reconnect restores from a snapshot.
|
||||
this.pendingByClient.delete(clientId)
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of batch.queue) {
|
||||
this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data)
|
||||
const socket = client.streamSocket
|
||||
// Sessions that held an entry must hold ALL their later entries in this
|
||||
// pass — writing around a held entry would reorder that session's bytes.
|
||||
const heldSessions = new Set<string>()
|
||||
const retained: PendingStreamDataBatch['queue'] = []
|
||||
while (batch.queue.length > 0) {
|
||||
const entry = batch.queue[0]
|
||||
if (entry.control) {
|
||||
// Control entries only respect the held-session order latch — they
|
||||
// are ~100B, so writing them onto a deep socket is as harmless as the
|
||||
// small-session bypass.
|
||||
if (heldSessions.has(entry.sessionId)) {
|
||||
retained.push(entry)
|
||||
batch.queue.shift()
|
||||
continue
|
||||
}
|
||||
batch.queue.shift()
|
||||
socket.write(encodeNdjson(entry.control))
|
||||
this.onAfterSocketWrite?.()
|
||||
continue
|
||||
}
|
||||
const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES
|
||||
if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) {
|
||||
const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0
|
||||
if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) {
|
||||
// Hold this flooding session's entry; small talkers keep flowing.
|
||||
// The socket's 'drain' (routed back to flush by the server)
|
||||
// resumes held bulk. No timer: a deep socket implies a prior
|
||||
// false write(), so 'drain' is guaranteed.
|
||||
heldSessions.add(entry.sessionId)
|
||||
retained.push(entry)
|
||||
batch.queue.shift()
|
||||
continue
|
||||
}
|
||||
} else if (socketDeep) {
|
||||
// Valve engaged: held bulk exceeded the memory cap and is being
|
||||
// written through onto a deep socket — echo protection is off until
|
||||
// it drains. Rare enough to be worth a diagnostics event every time.
|
||||
recordDaemonStreamBacklogEvent('heldWriteThrough', {
|
||||
heldChars: batch.queuedChars,
|
||||
socketBufferedBytes: socket.writableLength ?? 0
|
||||
})
|
||||
}
|
||||
const end =
|
||||
entry.data.length <= BULK_WRITE_SLICE_CHARS
|
||||
? entry.data.length
|
||||
: clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS)
|
||||
const slice = entry.data.slice(0, end)
|
||||
const entrySequenceChars = entry.sequenceChars ?? entry.data.length
|
||||
const sliceSequenceChars = entrySequenceChars === 0 ? 0 : slice.length
|
||||
if (end >= entry.data.length) {
|
||||
batch.queue.shift()
|
||||
} else {
|
||||
entry.data = entry.data.slice(end)
|
||||
const remainingSequenceChars = entrySequenceChars - sliceSequenceChars
|
||||
entry.sequenceChars =
|
||||
remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars
|
||||
}
|
||||
batch.queuedChars -= slice.length
|
||||
const sessionHeldAfter =
|
||||
(batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length
|
||||
if (sessionHeldAfter <= 0) {
|
||||
batch.queuedCharsBySession.delete(entry.sessionId)
|
||||
} else {
|
||||
batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter)
|
||||
}
|
||||
writeStreamDataEvents(socket, entry.sessionId, slice, this.maxLineBytes, sliceSequenceChars)
|
||||
this.onAfterSocketWrite?.()
|
||||
}
|
||||
if (retained.length > 0) {
|
||||
batch.queue = retained
|
||||
// Held entries must not wait for the socket's 'drain' alone: drain only
|
||||
// fires when the user-space buffer fully EMPTIES, so bulk would advance
|
||||
// one gate-depth per daemon event-loop turn — seconds of dead time for
|
||||
// a multi-MB hidden backlog on a busy daemon (measured: hidden-restore
|
||||
// 2.5s vs the 1.5s budget). Arm ONE ~90B empty data event whose
|
||||
// kernel-flush callback re-flushes while bytes are still in flight, so
|
||||
// main never starves. (An empty socket write's callback fires
|
||||
// immediately — verified — so the sentinel must be a real protocol
|
||||
// no-op line.) Event-driven, no timers; the per-client latch stops
|
||||
// sentinel stacking; 'drain' remains the backstop.
|
||||
this.armHeldQueueRefill(socket, clientId, retained[0].sessionId)
|
||||
return
|
||||
}
|
||||
this.pendingByClient.delete(clientId)
|
||||
}
|
||||
|
||||
private refillArmedClients = new Set<string>()
|
||||
|
||||
private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void {
|
||||
if (this.refillArmedClients.has(clientId) || socket.destroyed) {
|
||||
return
|
||||
}
|
||||
this.refillArmedClients.add(clientId)
|
||||
socket.write(encodeStreamDataEvent(sessionId, ''), () => {
|
||||
this.refillArmedClients.delete(clientId)
|
||||
this.flush(clientId)
|
||||
})
|
||||
}
|
||||
|
||||
private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number {
|
||||
@@ -203,6 +345,7 @@ export class DaemonStreamDataBatcher {
|
||||
|
||||
batch.queue = retained
|
||||
batch.queuedChars -= flushedChars
|
||||
batch.queuedCharsBySession.delete(sessionId)
|
||||
if (batch.queue.length === 0) {
|
||||
if (batch.timer) {
|
||||
clearTimeout(batch.timer)
|
||||
@@ -217,7 +360,19 @@ export class DaemonStreamDataBatcher {
|
||||
}
|
||||
|
||||
for (const entry of flushed) {
|
||||
this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data)
|
||||
if (entry.control) {
|
||||
client.streamSocket.write(encodeNdjson(entry.control))
|
||||
this.onAfterSocketWrite?.()
|
||||
} else {
|
||||
writeStreamDataEvents(
|
||||
client.streamSocket,
|
||||
entry.sessionId,
|
||||
entry.data,
|
||||
this.maxLineBytes,
|
||||
entry.sequenceChars ?? entry.data.length
|
||||
)
|
||||
this.onAfterSocketWrite?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +389,4 @@ export class DaemonStreamDataBatcher {
|
||||
this.pendingByClient.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private writeStreamDataEvent(streamSocket: Socket, sessionId: string, data: string): void {
|
||||
// Why: createNdjsonParser rejects oversized lines. Terminal output can
|
||||
// burst faster than the batch interval, so writer-side chunking prevents
|
||||
// the daemon from dropping its own stream events at the receiver.
|
||||
for (const chunk of splitStreamDataForNdjson(sessionId, data, this.maxLineBytes)) {
|
||||
streamSocket.write(encodeStreamDataEvent(sessionId, chunk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Surrogate-safe splitting for daemon stream data events: NDJSON line-size
|
||||
* chunking (the receiver's parser rejects oversized lines) and the safe-index
|
||||
* clamp shared by the batcher's bulk write slicing and keep-tail dropping.
|
||||
*/
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import type { Socket } from 'node:net'
|
||||
|
||||
export function encodeStreamDataEvent(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
sequenceChars?: number
|
||||
): string {
|
||||
return encodeNdjson({
|
||||
type: 'event',
|
||||
event: 'data',
|
||||
sessionId,
|
||||
payload: { data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }
|
||||
})
|
||||
}
|
||||
|
||||
function streamDataEventLineBytes(sessionId: string, data: string, sequenceChars?: number): number {
|
||||
return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, sequenceChars), 'utf8')
|
||||
}
|
||||
|
||||
function isHighSurrogate(value: number): boolean {
|
||||
return value >= 0xd800 && value <= 0xdbff
|
||||
}
|
||||
|
||||
function isLowSurrogate(value: number): boolean {
|
||||
return value >= 0xdc00 && value <= 0xdfff
|
||||
}
|
||||
|
||||
export function clampToSafeSplitIndex(value: string, start: number, end: number): number {
|
||||
if (end <= start || end >= value.length) {
|
||||
return end
|
||||
}
|
||||
const prev = value.charCodeAt(end - 1)
|
||||
const next = value.charCodeAt(end)
|
||||
return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end
|
||||
}
|
||||
|
||||
function nextSafeSplitIndex(value: string, start: number): number {
|
||||
const next = Math.min(value.length, start + 1)
|
||||
if (
|
||||
next < value.length &&
|
||||
isHighSurrogate(value.charCodeAt(start)) &&
|
||||
isLowSurrogate(value.charCodeAt(next))
|
||||
) {
|
||||
return next + 1
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function splitStreamDataForNdjson(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
maxLineBytes: number,
|
||||
sequenceChars?: number
|
||||
): string[] {
|
||||
if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) {
|
||||
return [data]
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
let start = 0
|
||||
while (start < data.length) {
|
||||
let low = start + 1
|
||||
let high = data.length
|
||||
let best = start
|
||||
|
||||
while (low <= high) {
|
||||
const rawMid = Math.floor((low + high) / 2)
|
||||
const mid = clampToSafeSplitIndex(data, start, rawMid)
|
||||
if (mid <= start) {
|
||||
low = rawMid + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes
|
||||
) {
|
||||
best = mid
|
||||
low = rawMid + 1
|
||||
} else {
|
||||
high = rawMid - 1
|
||||
}
|
||||
}
|
||||
|
||||
const end = best > start ? best : nextSafeSplitIndex(data, start)
|
||||
chunks.push(data.slice(start, end))
|
||||
start = end
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
export function writeStreamDataEvents(
|
||||
streamSocket: Pick<Socket, 'write'>,
|
||||
sessionId: string,
|
||||
data: string,
|
||||
maxLineBytes: number,
|
||||
sequenceChars = data.length
|
||||
): void {
|
||||
const explicitSequenceChars = sequenceChars === data.length ? undefined : sequenceChars
|
||||
for (const chunk of splitStreamDataForNdjson(
|
||||
sessionId,
|
||||
data,
|
||||
maxLineBytes,
|
||||
explicitSequenceChars
|
||||
)) {
|
||||
streamSocket.write(encodeStreamDataEvent(sessionId, chunk, explicitSequenceChars))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// ─── Events (Daemon → Client, on stream socket) ────────────────────
|
||||
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
|
||||
|
||||
export type DataEvent = {
|
||||
type: 'event'
|
||||
event: 'data'
|
||||
sessionId: string
|
||||
payload: { data: string; sequenceChars?: number }
|
||||
}
|
||||
|
||||
export type ExitEvent = {
|
||||
type: 'event'
|
||||
event: 'exit'
|
||||
sessionId: string
|
||||
payload: { code: number }
|
||||
}
|
||||
|
||||
export type TerminalErrorEvent = {
|
||||
type: 'event'
|
||||
event: 'terminalError'
|
||||
sessionId: string
|
||||
payload: { message: string }
|
||||
}
|
||||
|
||||
// Why these ride the stream socket (not control): each marks a POSITION in a
|
||||
// session's byte stream — scan-authority handoffs and dropped ranges are only
|
||||
// meaningful relative to the data events around them. Old mains ignore
|
||||
// unknown stream events, and only new mains send setSessionBackground. The
|
||||
// v20 bump is for sequence-safe recovery snapshots, not these tolerated events.
|
||||
|
||||
/** Scan-authority handoff marker: bytes before this event were (not) scanned
|
||||
* by the daemon's transient-fact relay; main flips its own scanners at
|
||||
* exactly this position so no fact double-fires or goes missing.
|
||||
* scanSeedAnsi (un-background only) carries the emulator's dangling
|
||||
* incomplete escape so main can prime its fresh scanner carry — a sequence
|
||||
* split across the handoff must not mint a phantom bell or lose its fact. */
|
||||
export type SessionBackgroundMarkerEvent = {
|
||||
type: 'event'
|
||||
event: 'sessionBackgroundMarker'
|
||||
sessionId: string
|
||||
payload: { background: boolean; scanSeedAnsi?: string }
|
||||
}
|
||||
|
||||
/** A backgrounded session's oldest undelivered output was dropped at the
|
||||
* daemon (keep-tail thinning). The daemon emulator ingested every byte —
|
||||
* only this monitoring stream is thinned. */
|
||||
export type DataGapEvent = {
|
||||
type: 'event'
|
||||
event: 'dataGap'
|
||||
sessionId: string
|
||||
payload: { droppedChars: number; sequenceChars?: number }
|
||||
}
|
||||
|
||||
/** Notification-bearing fact detected by the daemon while it holds scan
|
||||
* authority for a backgrounded session. Title/agent-status facts stay
|
||||
* main-side: they converge from the kept tail (stale-working-title timer,
|
||||
* snapshot-restores-title-state) and fuse with main-fabricated synthetic
|
||||
* frames the daemon never sees. */
|
||||
export type DaemonTransientFact =
|
||||
| { kind: 'bell' }
|
||||
| { kind: 'command-finished'; exitCode: number | null }
|
||||
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
|
||||
| { kind: '2031-subscribe' }
|
||||
|
||||
export type TransientFactEvent = {
|
||||
type: 'event'
|
||||
event: 'transientFact'
|
||||
sessionId: string
|
||||
payload: DaemonTransientFact
|
||||
}
|
||||
|
||||
export type DaemonEvent =
|
||||
| DataEvent
|
||||
| ExitEvent
|
||||
| TerminalErrorEvent
|
||||
| SessionBackgroundMarkerEvent
|
||||
| DataGapEvent
|
||||
| TransientFactEvent
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Keep-tail thinning for backgrounded sessions' queued stream data. Hidden
|
||||
* panes' stream copy is a monitoring feed (tail previews, agent status) — the
|
||||
* daemon emulator holds the complete model and reveal restores from its
|
||||
* snapshot. Once a backgrounded session's undelivered output exceeds the cap,
|
||||
* its OLDEST bytes are dropped down to the keep-tail and a dataGap event
|
||||
* takes their place, so the feed stays tail-fresh, daemon memory stays
|
||||
* bounded, and the producer is never paused (no reveal catch-up).
|
||||
*/
|
||||
import { clampToSafeSplitIndex } from './daemon-stream-data-split'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import type { DaemonEvent, DataGapEvent } from './types'
|
||||
|
||||
// A control entry carries a whole pre-shaped stream event (background marker,
|
||||
// data gap, transient fact) that must ride at its exact position in the
|
||||
// session's byte order; its data is always '' so it never counts against the
|
||||
// gate or drop caps, and drops never remove it.
|
||||
export type StreamQueueEntry = {
|
||||
sessionId: string
|
||||
data: string
|
||||
/** Original PTY characters represented by data. Salvaged query copies are
|
||||
* delivered bytes but represent zero new positions in the source stream. */
|
||||
sequenceChars?: number
|
||||
control?: DaemonEvent
|
||||
}
|
||||
|
||||
export type PendingStreamDataBatch = {
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
queue: StreamQueueEntry[]
|
||||
queuedChars: number
|
||||
// Per-session held totals so the flush hold can spare small talkers
|
||||
// (echo/replies) from waiting behind other sessions' floods.
|
||||
queuedCharsBySession: Map<string, number>
|
||||
// Last droppable-sessions-with-queued-data count seen by the keep-tail
|
||||
// logic: when it GROWS the shared budget tightens, and sessions that
|
||||
// finished producing must be re-trimmed (they will never re-enqueue).
|
||||
lastDroppableSessionCount?: number
|
||||
}
|
||||
|
||||
// The keep-tail must comfortably cover a full TUI repaint (~cols×rows×SGR ≈
|
||||
// 100KB) so the delivered tail always re-renders a coherent screen.
|
||||
// Hysteresis (cap = 2× keep) bounds drop churn.
|
||||
// Kill switch: ORCA_DAEMON_BACKGROUND_STREAM_DROP=0 disables thinning.
|
||||
const BACKGROUND_SESSION_KEEP_TAIL_CHARS = 512 * 1024
|
||||
const BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS = 64 * 1024
|
||||
// Why a GLOBAL budget too: the per-session cap bounds each flood, but N
|
||||
// backgrounded sessions can still queue N×cap in aggregate — and a reveal
|
||||
// (worktree switch) then waits behind the whole aggregate at the gated drain
|
||||
// rate (~8MB/s event-loop-turn-bound; measured 9MB queued → 2.5s hidden
|
||||
// restore vs the 1.5s budget). Shrinking each session's keep-tail as more
|
||||
// backgrounded sessions queue keeps the total ~2MB, so any reveal drains in
|
||||
// ~250ms while every pane still keeps at least a full screen of tail.
|
||||
const BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS = 2 * 1024 * 1024
|
||||
|
||||
export function backgroundSessionKeepTailChars(droppableSessionsWithQueuedData: number): number {
|
||||
return Math.min(
|
||||
BACKGROUND_SESSION_KEEP_TAIL_CHARS,
|
||||
Math.max(
|
||||
BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS,
|
||||
Math.floor(BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS / Math.max(1, droppableSessionsWithQueuedData))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function backgroundSessionDropCapChars(droppableSessionsWithQueuedData: number): number {
|
||||
return backgroundSessionKeepTailChars(droppableSessionsWithQueuedData) * 2
|
||||
}
|
||||
// Mirrors main's DROPPED_QUERY_SALVAGE_MAX_CHARS: salvage past this means a
|
||||
// pathological query stream; keep the O(1) memory guarantee. A prior drop's
|
||||
// salvage entry is itself the oldest data and re-salvages through the next
|
||||
// drop, so query order is preserved across repeated drops.
|
||||
const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096
|
||||
|
||||
/** Trim the session's OLDEST queued data down to the keep-tail and leave (or
|
||||
* grow) a dataGap control entry where the dropped bytes were. Control
|
||||
* entries are never dropped. Boundary note: the kept tail can start
|
||||
* mid-escape-sequence — deliberate; the receiver treats a gap as a
|
||||
* tail-preview reset and transient-fact scanning is daemon-authoritative
|
||||
* for droppable sessions, so nothing downstream parses across the cut. */
|
||||
export function dropOldestQueuedForSession(
|
||||
batch: PendingStreamDataBatch,
|
||||
sessionId: string,
|
||||
keepTailChars: number,
|
||||
salvageDroppedData: (dropped: string) => string
|
||||
): void {
|
||||
let toDrop = (batch.queuedCharsBySession.get(sessionId) ?? 0) - keepTailChars
|
||||
if (toDrop <= 0) {
|
||||
return
|
||||
}
|
||||
const totalDropped = toDrop
|
||||
let droppedSequenceChars = 0
|
||||
let salvaged = ''
|
||||
const salvageIntoCap = (dropped: string): void => {
|
||||
if (salvaged.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) {
|
||||
return
|
||||
}
|
||||
salvaged = (salvaged + salvageDroppedData(dropped)).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS)
|
||||
}
|
||||
let existingGap: DataGapEvent | null = null
|
||||
let insertGapAt = -1
|
||||
for (let i = 0; i < batch.queue.length && toDrop > 0; i++) {
|
||||
const entry = batch.queue[i]
|
||||
if (entry.sessionId !== sessionId) {
|
||||
continue
|
||||
}
|
||||
if (entry.control) {
|
||||
if (entry.control.event === 'dataGap') {
|
||||
existingGap = entry.control
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (entry.data.length <= toDrop) {
|
||||
toDrop -= entry.data.length
|
||||
droppedSequenceChars += entry.sequenceChars ?? entry.data.length
|
||||
salvageIntoCap(entry.data)
|
||||
if (insertGapAt === -1) {
|
||||
insertGapAt = i
|
||||
}
|
||||
batch.queue.splice(i, 1)
|
||||
i--
|
||||
} else {
|
||||
const cut = clampToSafeSplitIndex(entry.data, 0, toDrop)
|
||||
if (cut > 0) {
|
||||
const entrySequenceChars = entry.sequenceChars ?? entry.data.length
|
||||
const cutSequenceChars = entrySequenceChars === 0 ? 0 : cut
|
||||
droppedSequenceChars += cutSequenceChars
|
||||
salvageIntoCap(entry.data.slice(0, cut))
|
||||
entry.data = entry.data.slice(cut)
|
||||
const remainingSequenceChars = entrySequenceChars - cutSequenceChars
|
||||
entry.sequenceChars =
|
||||
remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars
|
||||
if (insertGapAt === -1) {
|
||||
insertGapAt = i
|
||||
}
|
||||
}
|
||||
toDrop = 0
|
||||
}
|
||||
}
|
||||
const dropped = totalDropped - toDrop
|
||||
if (dropped <= 0) {
|
||||
return
|
||||
}
|
||||
batch.queuedChars -= dropped
|
||||
batch.queuedCharsBySession.set(
|
||||
sessionId,
|
||||
Math.max(0, (batch.queuedCharsBySession.get(sessionId) ?? 0) - dropped)
|
||||
)
|
||||
if (existingGap) {
|
||||
const priorSequenceChars = existingGap.payload.sequenceChars ?? existingGap.payload.droppedChars
|
||||
existingGap.payload.droppedChars += dropped
|
||||
existingGap.payload.sequenceChars = priorSequenceChars + droppedSequenceChars
|
||||
} else {
|
||||
recordDaemonStreamBacklogEvent('backgroundKeepTailDrop', {
|
||||
sessionIdSuffix: sessionId.slice(-10),
|
||||
droppedChars: dropped
|
||||
})
|
||||
batch.queue.splice(Math.max(0, insertGapAt), 0, {
|
||||
sessionId,
|
||||
data: '',
|
||||
control: {
|
||||
type: 'event',
|
||||
event: 'dataGap',
|
||||
sessionId,
|
||||
payload: { droppedChars: dropped, sequenceChars: droppedSequenceChars }
|
||||
}
|
||||
})
|
||||
insertGapAt = Math.max(0, insertGapAt) + 1
|
||||
}
|
||||
if (salvaged.length > 0) {
|
||||
// Salvaged query bytes ride as a tiny data entry at the gap position —
|
||||
// the writing program is blocked on their replies.
|
||||
const at = existingGap
|
||||
? batch.queue.findIndex((e) => e.control === existingGap) + 1
|
||||
: insertGapAt
|
||||
batch.queue.splice(at, 0, { sessionId, data: salvaged, sequenceChars: 0 })
|
||||
batch.queuedChars += salvaged.length
|
||||
batch.queuedCharsBySession.set(
|
||||
sessionId,
|
||||
(batch.queuedCharsBySession.get(sessionId) ?? 0) + salvaged.length
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
|
||||
export async function shutdownDegradedFallbackSessions<T extends IPtyProvider>(
|
||||
sessionProviders: Map<string, T>,
|
||||
fallback: T
|
||||
): Promise<number> {
|
||||
const ids = [...sessionProviders]
|
||||
.filter(([, provider]) => provider === fallback)
|
||||
.map(([id]) => id)
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(async (id) => {
|
||||
await fallback.shutdown(id, { immediate: true })
|
||||
sessionProviders.delete(id)
|
||||
})
|
||||
)
|
||||
// Why: fallback cleanup must not abort the user's daemon-restart recovery path.
|
||||
const failed = results.filter((result) => result.status === 'rejected')
|
||||
if (failed.length > 0) {
|
||||
console.warn(
|
||||
`[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`,
|
||||
...failed.map((result) => (result as PromiseRejectedResult).reason)
|
||||
)
|
||||
}
|
||||
return results.length - failed.length
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
|
||||
type ProviderMock = IPtyProvider & {
|
||||
emitData: (id: string, data: string) => void
|
||||
emitData: (id: string, data: string, sequenceChars?: number) => void
|
||||
emitReplay: (id: string, data: string) => void
|
||||
emitExit: (id: string, code: number) => void
|
||||
}
|
||||
|
||||
function createProvider(label: string, sessions: string[] = []): ProviderMock {
|
||||
const dataListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
|
||||
[]
|
||||
const replayListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
const exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
return {
|
||||
@@ -41,15 +42,17 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock {
|
||||
listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))),
|
||||
getDefaultShell: vi.fn(async () => '/bin/zsh'),
|
||||
getProfiles: vi.fn(async () => []),
|
||||
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
|
||||
dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = dataListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
dataListeners.splice(idx, 1)
|
||||
onData: vi.fn(
|
||||
(callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => {
|
||||
dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = dataListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
dataListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
onReplay: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
|
||||
replayListeners.push(callback)
|
||||
return () => {
|
||||
@@ -68,9 +71,9 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock {
|
||||
}
|
||||
}
|
||||
}),
|
||||
emitData: (id: string, data: string) => {
|
||||
emitData: (id: string, data: string, sequenceChars?: number) => {
|
||||
for (const listener of dataListeners) {
|
||||
listener({ id, data })
|
||||
listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) })
|
||||
}
|
||||
},
|
||||
emitReplay: (id: string, data: string) => {
|
||||
@@ -151,6 +154,30 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
expect(fallback.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes authoritative recovery snapshots to the owning daemon', async () => {
|
||||
const current = createDaemonAdapter('daemon', ['daemon-session'])
|
||||
const fallback = createProvider('fallback')
|
||||
const snapshot = {
|
||||
data: 'alt frame',
|
||||
scrollbackAnsi: 'normal history',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 42,
|
||||
source: 'headless' as const
|
||||
}
|
||||
current.getBufferSnapshot = vi.fn(async () => snapshot)
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
|
||||
|
||||
await provider.discoverDaemonSessions()
|
||||
|
||||
await expect(
|
||||
provider.getBufferSnapshot('daemon-session', { scrollbackRows: 50_000 })
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(current.getBufferSnapshot).toHaveBeenCalledWith('daemon-session', {
|
||||
scrollbackRows: 50_000
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards replay output from fallback and daemon providers', () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const fallback = createProvider('fallback')
|
||||
@@ -174,6 +201,22 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves explicit sequence accounting on daemon data events', () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const fallback = createProvider('fallback')
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
|
||||
const dataSpy = vi.fn()
|
||||
provider.onData(dataSpy)
|
||||
|
||||
current.emitData('daemon-session', '\x1b[6n', 0)
|
||||
|
||||
expect(dataSpy).toHaveBeenCalledWith({
|
||||
id: 'daemon-session',
|
||||
data: '\x1b[6n',
|
||||
sequenceChars: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('detaches provider subscriptions without disposing the underlying providers', () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const fallback = createProvider('fallback')
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown'
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyBackgroundStreamEvent,
|
||||
PtyProviderBufferSnapshot,
|
||||
PtyProcessInfo,
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
@@ -23,7 +26,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
private fallback: ManagedPtyProvider
|
||||
private sessionProviders = new Map<string, ManagedPtyProvider>()
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
private dataListeners: ((payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
}) => void)[] = []
|
||||
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
|
||||
constructor(opts: {
|
||||
@@ -93,6 +100,18 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
this.providerFor(id).resize(id, cols, rows)
|
||||
}
|
||||
|
||||
pauseProducer(id: string): void {
|
||||
this.providerFor(id).pauseProducer?.(id)
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.providerFor(id).resumeProducer?.(id)
|
||||
}
|
||||
|
||||
setPtyBackgrounded(id: string, background: boolean): void {
|
||||
this.providerFor(id).setPtyBackgrounded?.(id, background)
|
||||
}
|
||||
|
||||
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
await this.providerFor(id).shutdown(id, opts)
|
||||
if (!opts.keepHistory) {
|
||||
@@ -116,6 +135,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
return (await this.providerFor(id).getAppliedSize?.(id)) ?? null
|
||||
}
|
||||
|
||||
async getBufferSnapshot(
|
||||
id: string,
|
||||
opts?: { scrollbackRows?: number }
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
// Why: a preserved legacy daemon can still thin its monitoring stream;
|
||||
// recovery must reach the adapter that owns that session's full model.
|
||||
return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null
|
||||
}
|
||||
|
||||
async clearBuffer(id: string): Promise<void> {
|
||||
await this.providerFor(id).clearBuffer(id)
|
||||
}
|
||||
@@ -155,7 +183,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
return this.fallback.getProfiles()
|
||||
}
|
||||
|
||||
onData(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
onData(
|
||||
callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
|
||||
): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.dataListeners.indexOf(callback)
|
||||
@@ -165,6 +195,17 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
const unsubscribes = this.allProviders().flatMap(
|
||||
(provider) => provider.onBackgroundStreamEvent?.(callback) ?? []
|
||||
)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReplay(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
const unsubscribes = this.allProviders().map((provider) => provider.onReplay(callback))
|
||||
let active = true
|
||||
@@ -237,28 +278,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
}
|
||||
|
||||
async shutdownFallbackSessions(): Promise<number> {
|
||||
const ids = [...this.sessionProviders]
|
||||
.filter(([, provider]) => provider === this.fallback)
|
||||
.map(([id]) => id)
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(async (id) => {
|
||||
await this.fallback.shutdown(id, { immediate: true })
|
||||
this.sessionProviders.delete(id)
|
||||
})
|
||||
)
|
||||
// Why: this runs first in the daemon-restart sequence. A throw here would
|
||||
// abort the whole restart and leave "Restart daemon" — the user's recovery
|
||||
// path for a wedged terminal — unusable, recreating the original lockup. So
|
||||
// it is best-effort: log failures, keep restarting, and only count the
|
||||
// sessions that actually shut down.
|
||||
const failed = results.filter((result) => result.status === 'rejected')
|
||||
if (failed.length > 0) {
|
||||
console.warn(
|
||||
`[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`,
|
||||
...failed.map((result) => (result as PromiseRejectedResult).reason)
|
||||
)
|
||||
}
|
||||
return results.length - failed.length
|
||||
return shutdownDegradedFallbackSessions(this.sessionProviders, this.fallback)
|
||||
}
|
||||
|
||||
getCurrentDaemonSessionIds(): string[] {
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import {
|
||||
buildAgentTuiStreamOps,
|
||||
mulberry32,
|
||||
splitIntoRandomChunks,
|
||||
type AgentTuiStreamDims
|
||||
} from '../../shared/agent-tui-ansi-fuzz-stream'
|
||||
import {
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_ALT,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
bufferHasSerializeHostileWrappedRow,
|
||||
createRendererParityTerminal,
|
||||
cursorPosition,
|
||||
normalBufferRowsTrimmed,
|
||||
visibleRowStyles,
|
||||
visibleRows,
|
||||
writeChunksToTerminal
|
||||
} from '../../shared/terminal-restore-parity-fixture'
|
||||
|
||||
// Differential garble gate for the hidden-terminal model/view contract
|
||||
// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery
|
||||
// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the
|
||||
// source of truth and reveal repaints the renderer xterm from
|
||||
// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot).
|
||||
// This fuzz feeds seeded agent-TUI byte streams to the production emulator
|
||||
// and to an always-visible renderer-parity terminal, then asserts the
|
||||
// serialize→replay round trip reproduces the exact screen the renderer would
|
||||
// have shown. Any diff = a garble bug on reveal.
|
||||
//
|
||||
// Runtime knobs:
|
||||
// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with
|
||||
// the reveal-reconciliation suite)
|
||||
// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log)
|
||||
|
||||
const DEFAULT_ITERATIONS = 300
|
||||
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
|
||||
const ITERATIONS =
|
||||
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
|
||||
// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the
|
||||
// scrollback budget the reveal restore actually requests from main.
|
||||
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
|
||||
|
||||
function readPositiveIntEnv(name: string): number | null {
|
||||
const raw = Number(process.env[name])
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null
|
||||
}
|
||||
|
||||
const DIMS: readonly AgentTuiStreamDims[] = [
|
||||
{ cols: 80, rows: 24 },
|
||||
{ cols: 100, rows: 30 },
|
||||
{ cols: 120, rows: 40 }
|
||||
]
|
||||
|
||||
type FidelityCase = {
|
||||
seed: number
|
||||
dims: AgentTuiStreamDims
|
||||
ops: string[]
|
||||
chunked: boolean
|
||||
}
|
||||
|
||||
type FidelityDiff = {
|
||||
stage: string
|
||||
expected: unknown
|
||||
actual: unknown
|
||||
/** True when the always-visible buffer matches the known upstream
|
||||
* @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see
|
||||
* bufferHasSerializeHostileWrappedRow and the skipped repro test below. */
|
||||
knownSerializeWrapBug?: boolean
|
||||
}
|
||||
|
||||
function buildCase(seed: number): FidelityCase {
|
||||
const rng = mulberry32(seed)
|
||||
const dims = DIMS[Math.floor(rng() * DIMS.length)]!
|
||||
const opCount = 12 + Math.floor(rng() * 28)
|
||||
const ops = buildAgentTuiStreamOps(rng, dims, {
|
||||
includeMouseModes: true,
|
||||
includeOscHyperlinks: false,
|
||||
opCount
|
||||
})
|
||||
return { seed, dims, ops, chunked: true }
|
||||
}
|
||||
|
||||
function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null {
|
||||
return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual }
|
||||
}
|
||||
|
||||
async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | null> {
|
||||
const stream = testCase.ops.join('')
|
||||
const chunks = testCase.chunked
|
||||
? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, {
|
||||
minLen: 3,
|
||||
maxLen: 120
|
||||
})
|
||||
: [stream]
|
||||
const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows })
|
||||
const control = createRendererParityTerminal(testCase.dims)
|
||||
const restored = createRendererParityTerminal(testCase.dims)
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, chunks)
|
||||
|
||||
// Stage 1 — model fidelity: the emulator's screen must already match the
|
||||
// renderer twin before any serialization enters the picture.
|
||||
const modelDiff = firstDiff(
|
||||
'model-visible (HeadlessEmulator vs renderer twin)',
|
||||
visibleRows(control.terminal),
|
||||
emulator.getVisibleLines()
|
||||
)
|
||||
if (modelDiff) {
|
||||
return modelDiff
|
||||
}
|
||||
|
||||
// Stage 2 — reveal round trip: serialize exactly like
|
||||
// serializeHiddenOutputRecoveryBuffer, replay exactly like
|
||||
// applyMainBufferSnapshot, then compare against the always-visible twin.
|
||||
const alt = emulator.isAlternateScreen
|
||||
const snapshot = emulator.getSnapshot({
|
||||
scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS
|
||||
})
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
|
||||
const diffs = [
|
||||
firstDiff(
|
||||
'restore-visible-text',
|
||||
visibleRows(control.terminal),
|
||||
visibleRows(restored.terminal)
|
||||
),
|
||||
firstDiff(
|
||||
'restore-visible-styles',
|
||||
visibleRowStyles(control.terminal),
|
||||
visibleRowStyles(restored.terminal)
|
||||
),
|
||||
firstDiff(
|
||||
'restore-cursor',
|
||||
cursorPosition(control.terminal),
|
||||
cursorPosition(restored.terminal)
|
||||
),
|
||||
firstDiff(
|
||||
'restore-mode-bracketed-paste',
|
||||
control.terminal.modes.bracketedPasteMode,
|
||||
restored.terminal.modes.bracketedPasteMode
|
||||
),
|
||||
// Why alt is excluded from the two comparisons below:
|
||||
// - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts)
|
||||
// deliberately forces scrollbackRows=0 while an alt-screen TUI is
|
||||
// active, so normal-buffer history is not part of the alt contract.
|
||||
// - application cursor: HeadlessEmulator.getModes reports
|
||||
// applicationCursor false on the alternate buffer, so rehydrate omits
|
||||
// ?1h there by design.
|
||||
alt
|
||||
? null
|
||||
: firstDiff(
|
||||
'restore-scrollback-text',
|
||||
normalBufferRowsTrimmed(control.terminal),
|
||||
normalBufferRowsTrimmed(restored.terminal)
|
||||
),
|
||||
alt
|
||||
? null
|
||||
: firstDiff(
|
||||
'restore-mode-application-cursor',
|
||||
control.terminal.modes.applicationCursorKeysMode,
|
||||
restored.terminal.modes.applicationCursorKeysMode
|
||||
)
|
||||
]
|
||||
const diff = diffs.find((candidate) => candidate !== null) ?? null
|
||||
if (!diff) {
|
||||
return null
|
||||
}
|
||||
if (bufferHasSerializeHostileWrappedRow(control.terminal)) {
|
||||
return { ...diff, knownSerializeWrapBug: true }
|
||||
}
|
||||
return diff
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Greedy op-drop minimizer: re-runs the full differential pipeline on
|
||||
* smaller op lists so a failure report carries the smallest byte stream that
|
||||
* still diverges (plus its seed for exact replay via FUZZ_SEED). */
|
||||
async function minimizeFailure(testCase: FidelityCase): Promise<FidelityCase> {
|
||||
let current = { ...testCase, chunked: false }
|
||||
if ((await runFidelityCase(current)) === null) {
|
||||
current = { ...testCase, chunked: true }
|
||||
}
|
||||
let budget = 400
|
||||
let shrunk = true
|
||||
while (shrunk && budget > 0) {
|
||||
shrunk = false
|
||||
for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) {
|
||||
const candidate = { ...current, ops: current.ops.toSpliced(i, 1) }
|
||||
budget -= 1
|
||||
if ((await runFidelityCase(candidate)) !== null) {
|
||||
current = candidate
|
||||
shrunk = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string {
|
||||
return [
|
||||
`HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`,
|
||||
`seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`,
|
||||
`dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`,
|
||||
`minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`,
|
||||
`expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`,
|
||||
`actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('headless emulator snapshot fidelity fuzz', () => {
|
||||
// Known-legitimate divergence, pinned so it cannot silently regress into a
|
||||
// real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon
|
||||
// never re-emits OSC 8, and production compensates by shipping the ranges
|
||||
// out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the
|
||||
// renderer link provider to re-register. Byte-replay therefore keeps the
|
||||
// TEXT but not the link underline — the metadata must carry the range.
|
||||
it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 60, rows: 10 })
|
||||
const restored = createRendererParityTerminal({ cols: 60, rows: 10 })
|
||||
try {
|
||||
await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail')
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
expect(visibleRows(restored.terminal)[0]).toBe('review link tail')
|
||||
expect(snapshot.oscLinks).toContainEqual({
|
||||
row: 0,
|
||||
startCol: 0,
|
||||
endCol: 11,
|
||||
uri: 'https://example.com/pr/7'
|
||||
})
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => {
|
||||
// The known-and-pinned serialize wrap bug (A) is tolerated + counted so
|
||||
// deep mode (FUZZ_ITERATIONS) surfaces only GENUINELY NEW divergences.
|
||||
// Bugs B (bold-reset, fixed by the addon patch) and C (margin cursor,
|
||||
// fixed by the absolute-cursor epilogue) are no longer tolerated — a
|
||||
// regression fails the corpus loudly and the unskipped repros below.
|
||||
let knownSerializeWrapBugHits = 0
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const seed = FIXED_SEED ?? 1 + i
|
||||
const testCase = buildCase(seed)
|
||||
const diff = await runFidelityCase(testCase)
|
||||
if (diff?.knownSerializeWrapBug) {
|
||||
knownSerializeWrapBugHits += 1
|
||||
continue
|
||||
}
|
||||
if (diff) {
|
||||
const minimized = await minimizeFailure(testCase)
|
||||
const minimizedDiff = await runFidelityCase(minimized)
|
||||
expect.fail(formatFailure(minimized, minimizedDiff ?? diff))
|
||||
}
|
||||
}
|
||||
// Guard the tolerance from swallowing the suite: the predicate tripping
|
||||
// on most seeds means the gate has gone degenerate.
|
||||
expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
|
||||
}, 600_000)
|
||||
|
||||
// ── HEADLINE FINDING (do not delete while unfixed upstream) ──────────────
|
||||
// @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells
|
||||
// that touch a soft-wrap boundary. Two variants, both found by this fuzz
|
||||
// and minimized below. Every Orca snapshot consumer is affected: hidden
|
||||
// reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe
|
||||
// replay paint lost/shifted characters or stray '-' fillers whenever a TUI
|
||||
// erased inside a soft-wrapped line (shell line editing, status lines wider
|
||||
// than the pane, Claude Code in-place prompt redraws).
|
||||
//
|
||||
// V1 — cell loss (found by seed 31, minimized to 2 ops):
|
||||
// Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214)
|
||||
// nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth
|
||||
// ? this._nullCellCount <= 1 : this._nullCellCount <= 0
|
||||
// binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row
|
||||
// passes as a "natural" wrap. The serializer then emits the previous row as
|
||||
// full-width text (leaving xterm in wrap-pending) and skips the null cell
|
||||
// with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of
|
||||
// crossing the wrap boundary, so the next character overwrites the previous
|
||||
// row's last cell and the whole tail shifts left by one.
|
||||
// cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'
|
||||
// live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12']
|
||||
// serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D'
|
||||
// replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted
|
||||
//
|
||||
// V2 — stray filler '-' (found by seed 157, minimized below): when the
|
||||
// SOURCE row of a wrapped pair is entirely null (a TUI erased the whole
|
||||
// first half of a wrapped line), the addon's forced-wrap "magic" writes
|
||||
// nullCellCount+1 dashes and then cleans up with
|
||||
// ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ...
|
||||
// With length === nullCellCount that cursor-forward becomes `ESC[0C`, and
|
||||
// CSI param 0 means 1, so the ECH erase lands one cell right and the first
|
||||
// '-' stays visible on the restored row.
|
||||
// Unskip once the upstream fix (or a local serialize post-processor) lands.
|
||||
it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
|
||||
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
try {
|
||||
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// V2 repro of the headline finding above (stray '-' filler on a fully
|
||||
// erased wrapped source row). Unskip alongside the V1 repro.
|
||||
it.skip('round-trips a wrapped line whose source row was fully erased', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
|
||||
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
|
||||
try {
|
||||
// Wrap a 28-char line, then erase the entire first (source) row of the
|
||||
// wrapped pair: cursor up twice onto it, EL 2.
|
||||
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
// Fails today: restored row 0 shows '-' where the live row is blank.
|
||||
expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Bug B regression guard: SGR bold on a dim→bold-only cell transition ──
|
||||
// Upstream @xterm/addon-serialize emitted `\x1b[1;22m` for this transition;
|
||||
// SGR 22 (normalIntensity) clears BOTH bold and dim, so the restored cell
|
||||
// lost its bold. FIXED by the intensity-group reorder in
|
||||
// config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch (22 before
|
||||
// 1/2). Found by fuzz seeds 435, 770, 1321; mechanism in
|
||||
// notes/garble-fuzz-divergences.md (Bug B).
|
||||
it('preserves bold when serializing a dim cell followed by a bold-only cell', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 4 })
|
||||
const control = createRendererParityTerminal({ cols: 20, rows: 4 })
|
||||
const restored = createRendererParityTerminal({ cols: 20, rows: 4 })
|
||||
try {
|
||||
// 'A' dim, 'B' bold-only. Live: A=dim, B=bold. The patched serializer
|
||||
// emits 22;1 for the B transition (clear before re-set).
|
||||
const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Bug C regression guard: cursor exact when the last row fills the margin ──
|
||||
// Upstream @xterm/addon-serialize computes its relative cursor-restore from
|
||||
// a wrap-pending position and lands one column short. FIXED Orca-side: the
|
||||
// emulator snapshot appends an absolute CUP from the source's authoritative
|
||||
// cursor (serializeWithAbsoluteCursor). Found by fuzz seeds 454, 1696;
|
||||
// mechanism in notes/garble-fuzz-divergences.md (Bug C).
|
||||
it('restores the cursor exactly when the last content row fills the right margin', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 10, rows: 4 })
|
||||
const control = createRendererParityTerminal({ cols: 10, rows: 4 })
|
||||
const restored = createRendererParityTerminal({ cols: 10, rows: 4 })
|
||||
try {
|
||||
// Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a
|
||||
// known lower-row column. Live cursor is (x=4, y=2).
|
||||
const bytes = ['0123456789\x1b[3;5H']
|
||||
for (const chunk of bytes) {
|
||||
await emulator.write(chunk)
|
||||
}
|
||||
await writeChunksToTerminal(control.terminal, bytes)
|
||||
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
|
||||
])
|
||||
expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal))
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
control.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,74 @@ describe('HeadlessEmulator', () => {
|
||||
uri: 'https://example.com/issue/1234'
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes split synchronized rich TUI frames for model-backed replay', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 12 })
|
||||
const richFrame = [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'\x1b[?25l',
|
||||
'\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n',
|
||||
'\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n',
|
||||
'\x1b[2;36m│ status streaming │\x1b[0m\r\n',
|
||||
'\x1b[2;36m╰────────────────────────────╯\x1b[0m',
|
||||
'\x1b[6;4H\x1b[?25h',
|
||||
'\x1b[?2026l'
|
||||
].join('')
|
||||
|
||||
// Why: hidden rich TUI bytes may arrive split across DEC 2026 frame
|
||||
// boundaries; model/view work needs the headless model to preserve the
|
||||
// final visible state before renderer writes can be removed.
|
||||
await emulator.write(richFrame.slice(0, 17))
|
||||
await emulator.write(richFrame.slice(17, 91))
|
||||
await emulator.write(richFrame.slice(91))
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.modes.alternateScreen).toBe(true)
|
||||
expect(snapshot.snapshotAnsi).toContain('Codex rich restore')
|
||||
expect(snapshot.snapshotAnsi).toContain('🟢')
|
||||
expect(snapshot.snapshotAnsi).toContain('███░')
|
||||
expect(snapshot.snapshotAnsi).toContain('╭')
|
||||
expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h')
|
||||
|
||||
const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows })
|
||||
try {
|
||||
await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi)
|
||||
const replayed = replay.getSnapshot()
|
||||
expect(replayed.modes.alternateScreen).toBe(true)
|
||||
expect(replayed.snapshotAnsi).toContain('Codex rich restore')
|
||||
expect(replayed.snapshotAnsi).toContain('🟢')
|
||||
expect(replayed.snapshotAnsi).toContain('███░')
|
||||
} finally {
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the normal buffer behind an alternate-screen snapshot', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 40, rows: 6 })
|
||||
await emulator.write('shell history one\r\nshell history two')
|
||||
await emulator.write('\x1b[?1049h\x1b[2J\x1b[HTUI frame')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.scrollbackAnsi).toContain('shell history one')
|
||||
expect(snapshot.snapshotAnsi).toContain('TUI frame')
|
||||
expect(snapshot.snapshotAnsi).not.toContain('shell history one')
|
||||
|
||||
const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows })
|
||||
try {
|
||||
await replay.write(
|
||||
snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
)
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('TUI frame')
|
||||
|
||||
await replay.write('\x1b[?1049l')
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('shell history one')
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('shell history two')
|
||||
} finally {
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSC-7 CWD tracking', () => {
|
||||
@@ -305,6 +373,49 @@ describe('HeadlessEmulator', () => {
|
||||
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks kitty keyboard flags for emulator re-seed parity', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0)
|
||||
|
||||
await emulator.write('\x1b[=5;1u')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5)
|
||||
})
|
||||
|
||||
it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => {
|
||||
// Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags,
|
||||
// a private xterm surface. If an xterm upgrade breaks that path this
|
||||
// must fail loudly instead of the responder silently answering ?0u.
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
|
||||
await emulator.write('\x1b[>1u')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1)
|
||||
})
|
||||
|
||||
it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
// Kitty flags are per screen buffer: entering the alt screen swaps to
|
||||
// its own (empty) flag set, exactly what a CSI ? u reply would report.
|
||||
await emulator.write('\x1b[=5;1u\x1b[?1049h')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0)
|
||||
|
||||
await emulator.write('\x1b[=3;1u')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3)
|
||||
|
||||
await emulator.write('\x1b[?1049l')
|
||||
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5)
|
||||
})
|
||||
|
||||
it('never pushes kitty flags into rehydrateSequences', async () => {
|
||||
// Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay
|
||||
// authoritative for renderer replays (terminal-query-authority.md).
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('\x1b[?1049h\x1b[=5;1u')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.modes.kittyKeyboardFlags).toBe(5)
|
||||
expect(snapshot.rehydrateSequences).not.toContain('u')
|
||||
})
|
||||
|
||||
it('tracks split SGR mouse reporting sequences', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
|
||||
@@ -499,14 +610,16 @@ describe('HeadlessEmulator', () => {
|
||||
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
|
||||
})
|
||||
|
||||
it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => {
|
||||
it('records kitty flags without pushing them into renderer rehydration', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
// OMP/pi negotiate progressive enhancement with a level-1 push.
|
||||
await emulator.write('\x1b[>1u')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.modes.kittyKeyboardFlags).toBe(1)
|
||||
expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u')
|
||||
// Why: renderer replay deliberately resets stale CSI-u state; the daemon
|
||||
// warm-reattach path re-seeds the model from modes.kittyKeyboardFlags.
|
||||
expect(snapshot.rehydrateSequences).not.toContain('\x1b[=1;1u')
|
||||
})
|
||||
|
||||
it('omits kitty rehydration after the TUI pops its flags', async () => {
|
||||
@@ -519,7 +632,7 @@ describe('HeadlessEmulator', () => {
|
||||
expect(snapshot.rehydrateSequences).not.toContain('u')
|
||||
})
|
||||
|
||||
it('re-arms kitty flags after the alt-screen switch so they land on the negotiated screen', async () => {
|
||||
it('keeps kitty flags out of alternate-screen renderer rehydration', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('\x1b[?1049h\x1b[>1u')
|
||||
|
||||
@@ -527,7 +640,7 @@ describe('HeadlessEmulator', () => {
|
||||
const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h')
|
||||
const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u')
|
||||
expect(altScreenIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(kittyIndex).toBeGreaterThan(altScreenIndex)
|
||||
expect(kittyIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => {
|
||||
|
||||
@@ -3,13 +3,21 @@ import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
|
||||
import {
|
||||
readSavedCursorRegister,
|
||||
serializeWithAbsoluteCursor
|
||||
} from '../../shared/terminal-serialize-absolute-cursor'
|
||||
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { extractLastOscTitle } from '../../shared/agent-detection'
|
||||
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
||||
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
|
||||
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
|
||||
import { parseFileUriPath } from './osc7-file-uri'
|
||||
import { TerminalPrivateModeTracker } from './terminal-private-mode-tracker'
|
||||
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
|
||||
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
|
||||
import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers'
|
||||
import {
|
||||
installTerminalViewAttributeResponder,
|
||||
type TerminalViewAttributeResponder
|
||||
} from './terminal-view-attribute-responder'
|
||||
import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
|
||||
@@ -17,48 +25,87 @@ export type HeadlessEmulatorOptions = {
|
||||
cols: number
|
||||
rows: number
|
||||
scrollback?: number
|
||||
/** Phase-5 model query responder sink (terminal-query-authority.md).
|
||||
* When set, xterm-core auto-replies generated while parsing a write
|
||||
* flagged `forwardQueryReplies` are forwarded here; all other emissions
|
||||
* (seeds, hydration, snapshot replay, unsolicited core pushes) are
|
||||
* discarded. The daemon Session must NEVER pass this — its emulator
|
||||
* stays write-only forever (contract invariant: the daemon never
|
||||
* answers). */
|
||||
onQueryReply?: (reply: string) => void
|
||||
pathFlavor?: 'posix' | 'win32'
|
||||
remotePosixFileUriAuthority?: boolean
|
||||
}
|
||||
|
||||
export type HeadlessEmulatorWriteOptions = {
|
||||
/** Reply ownership captured at ingestion for this exact chunk. Default
|
||||
* false is the main-side replay guard (twin of the renderer's
|
||||
* replay-guard.ts): seed/hydration/snapshot writes never forward. */
|
||||
forwardQueryReplies?: boolean
|
||||
}
|
||||
|
||||
type TerminalWithSynchronousWrite = Terminal & {
|
||||
_core?: {
|
||||
writeSync?: (data: string) => void
|
||||
// Why: kitty keyboard flags are not on the public IModes; read the core
|
||||
// service state the CSI =/>/< u handlers mutate.
|
||||
coreService?: {
|
||||
kittyKeyboard?: { flags?: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SCROLLBACK = 5000
|
||||
const OSC_SCAN_TAIL_LIMIT = 4096
|
||||
// Keep in sync with the renderer twin in terminal-capability-replies.ts
|
||||
// (main must not import renderer modules).
|
||||
const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
|
||||
|
||||
export class HeadlessEmulator {
|
||||
private terminal: Terminal
|
||||
private serializer: SerializeAddon
|
||||
private cwd: string | null = null
|
||||
private lastTitle: string | null = null
|
||||
private oscScanTail = ''
|
||||
private privateModes = new TerminalPrivateModeTracker()
|
||||
private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker()
|
||||
private restoredOscLinks: TerminalOscLinkRange[] = []
|
||||
// Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's
|
||||
// parser, not the screen buffer, so serialize() drops them and the next
|
||||
// chunk's continuation renders literally after a remote snapshot restore
|
||||
// (#7329). Track the unparsed trailing partial at ingest (committed after
|
||||
// xterm parses the same bytes, like the private-mode mirror) and ship it in
|
||||
// the snapshot so the restorer can complete the sequence.
|
||||
private partialEscapeTail = ''
|
||||
private disposed = false
|
||||
// Why: our restructure owns cwd/title via TerminalOscCwdTitleScanner and the
|
||||
// DECSET mouse modes via TerminalMouseModeMirror (functionally identical to
|
||||
// main's inline cwd/lastTitle/oscScanTail + TerminalPrivateModeTracker, which
|
||||
// only tracks the same mouse modes). restoredOscLinks/disposed/partialEscapeTail
|
||||
// are declared below.
|
||||
private oscText: TerminalOscCwdTitleScanner
|
||||
private mouseModes = new TerminalMouseModeMirror()
|
||||
private readonly pathFlavor?: 'posix' | 'win32'
|
||||
private readonly remotePosixFileUriAuthority: boolean
|
||||
private restoredOscLinks: TerminalOscLinkRange[] = []
|
||||
private disposed = false
|
||||
private onQueryReply: ((reply: string) => void) | null
|
||||
private conptyDa1OverrideInstalled = false
|
||||
private viewAttributeResponder: TerminalViewAttributeResponder | null = null
|
||||
// Why: replies must be scoped to the exact write that carried the query.
|
||||
// The window opens around the parse of a forward-flagged chunk and closes
|
||||
// with it, so seeds/snapshots and unsolicited core emissions (e.g. native
|
||||
// 997 pushes from option mutations) can never leak to the PTY.
|
||||
private queryReplyForwardingDepth = 0
|
||||
// Why: a chunk ending mid-escape leaves the sequence in xterm's parser, not
|
||||
// the buffer, so serialize() drops it and the next chunk's continuation
|
||||
// renders literal after a restore (Bug E, notes/garble-fuzz-divergences.md).
|
||||
// Committed alongside mouseModes: only after xterm parsed the same bytes.
|
||||
private partialEscapeTail = ''
|
||||
|
||||
constructor(opts: HeadlessEmulatorOptions) {
|
||||
this.pathFlavor = opts.pathFlavor
|
||||
this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true
|
||||
this.oscText = new TerminalOscCwdTitleScanner({
|
||||
pathFlavor: this.pathFlavor,
|
||||
remotePosixAuthority: this.remotePosixFileUriAuthority
|
||||
})
|
||||
this.terminal = new Terminal({
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
|
||||
allowProposedApi: true,
|
||||
logLevel: 'off'
|
||||
logLevel: 'off',
|
||||
// Why: parity with the renderer's buildDefaultTerminalOptions — parse
|
||||
// CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app
|
||||
// actually pushed. Write-only daemon use is unaffected: keyboard state
|
||||
// never alters serialization (terminal-query-authority.md §kitty).
|
||||
vtExtensions: { kittyKeyboard: true }
|
||||
})
|
||||
|
||||
this.serializer = new SerializeAddon()
|
||||
@@ -72,34 +119,136 @@ export class HeadlessEmulator {
|
||||
this.terminal.loadAddon(new Unicode11Addon())
|
||||
activateOrcaTerminalUnicodeProvider(this.terminal)
|
||||
|
||||
// Why no onData wiring: this emulator exists purely for state tracking
|
||||
// (snapshots, cwd, mode flags). It MUST NOT respond to terminal query
|
||||
// sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses
|
||||
// data in-process synchronously before `handleSubprocessData` forwards
|
||||
// it to the renderer over IPC, so any reply it emits would land on the
|
||||
// shell's stdin ahead of the renderer's xterm reply and win the race.
|
||||
// The renderer is the authoritative responder (it has the real theme,
|
||||
// cursor position, and paste mode); a daemon-side reply would be a
|
||||
// double-reply with wrong values. OSC 11 was the visible casualty:
|
||||
// Claude Code's /theme auto always saw the emulator's default-black
|
||||
// background regardless of Orca's configured terminal theme.
|
||||
// Why onData is gated behind onQueryReply: by default this emulator is
|
||||
// pure state tracking and MUST NOT respond to terminal query sequences
|
||||
// (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data
|
||||
// in-process synchronously before `handleSubprocessData` forwards it to
|
||||
// the renderer over IPC, so any reply it emitted would land on the
|
||||
// shell's stdin ahead of the renderer's xterm reply and win the race —
|
||||
// a double-reply with default-xterm values (OSC 11 default-black was
|
||||
// the visible casualty). Only main's runtime per-PTY emulators pass a
|
||||
// sink, and even then replies flow only for chunks the hidden-delivery
|
||||
// gate DROPPED, where the renderer never sees the bytes and main is the
|
||||
// single answerer. See docs/reference/terminal-query-authority.md.
|
||||
this.onQueryReply = opts.onQueryReply ?? null
|
||||
if (this.onQueryReply) {
|
||||
this.terminal.onData((reply) => this.emitQueryReply(reply))
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): Promise<void> {
|
||||
/** Main-side twin of the renderer's terminal-capability-replies.ts:
|
||||
* ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override
|
||||
* variant (`CSI ?61;4c`) must win. Returning true consumes the query so
|
||||
* xterm core's default `?1;2c` cannot double-reply (custom CSI handlers
|
||||
* run before core's; false falls through). The reply still routes through
|
||||
* the forwarding window, so replayed/seeded bytes never answer. */
|
||||
installConptyPrimaryDeviceAttributesOverride(): void {
|
||||
// Why idempotent: the spawn mark can land after daemon stream data
|
||||
// already created the emulator, so the override is installed both at
|
||||
// creation and retrofitted at mark time — never stacked.
|
||||
if (this.conptyDa1OverrideInstalled) {
|
||||
return
|
||||
}
|
||||
this.conptyDa1OverrideInstalled = true
|
||||
this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => {
|
||||
const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0)
|
||||
if (!isPrimaryQuery) {
|
||||
return false
|
||||
}
|
||||
this.emitQueryReply(CONPTY_DA1_RESPONSE)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Phase-5 slice-2 view-attribute bridge: the headless core has no theme
|
||||
* service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the
|
||||
* renderer's pushed attributes via these parser handlers — never from
|
||||
* emulator defaults. Runtime-only, like onQueryReply: the daemon Session
|
||||
* must NEVER call this (its emulator stays write-only forever). */
|
||||
installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void {
|
||||
if (this.viewAttributeResponder) {
|
||||
return
|
||||
}
|
||||
this.viewAttributeResponder = installTerminalViewAttributeResponder({
|
||||
parser: this.terminal.parser,
|
||||
getBaseAttributes,
|
||||
// emitQueryReply keeps replies inside the per-chunk forwarding window,
|
||||
// so seeded/replayed view-attribute queries answer no one.
|
||||
emitReply: (reply) => this.emitQueryReply(reply)
|
||||
})
|
||||
}
|
||||
|
||||
/** Applies a renderer view-attribute push: cursor options make xterm core
|
||||
* answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC
|
||||
* color overrides are dropped because a theme apply overwrites mutated
|
||||
* colors on visible panes too (ThemeService._setTheme parity). Option
|
||||
* writes happen outside any forwarding window, so any core emission they
|
||||
* trigger is discarded (main-side replay guard). */
|
||||
applyPushedViewAttributes(attributes: TerminalViewAttributes): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.terminal.options.cursorStyle = attributes.cursorStyle
|
||||
this.terminal.options.cursorBlink = attributes.cursorBlink
|
||||
this.viewAttributeResponder?.clearColorOverrides()
|
||||
}
|
||||
|
||||
/** Re-seed parity for snapshot `modes.kittyKeyboardFlags`
|
||||
* (terminal-query-authority.md §kitty): replays the persisted flags
|
||||
* through the same `CSI = flags ; 1 u` parse a live push uses, so hidden
|
||||
* `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write —
|
||||
* outside any forwarding window, it can never answer anything — and never
|
||||
* into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty
|
||||
* reset stays authoritative). */
|
||||
applyKittyKeyboardFlags(flags: number): Promise<void> {
|
||||
if (!Number.isInteger(flags) || flags <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return this.write(`\x1b[=${flags};1u`)
|
||||
}
|
||||
|
||||
private emitQueryReply(reply: string): void {
|
||||
if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) {
|
||||
this.onQueryReply(reply)
|
||||
}
|
||||
}
|
||||
|
||||
/** Severs the reply sink at PTY teardown. Queued writeChain links may
|
||||
* still parse after dispose is requested, and daemon respawns reuse
|
||||
* session ids — a late reply must never reach a successor PTY. */
|
||||
disableQueryReplyForwarding(): void {
|
||||
this.onQueryReply = null
|
||||
}
|
||||
|
||||
write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (this.tryWriteSync(data)) {
|
||||
const forwardQueryReplies = opts.forwardQueryReplies === true
|
||||
if (this.tryWriteSync(data, { forwardQueryReplies })) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.scanInputForOscState(data)
|
||||
this.oscText.scan(data)
|
||||
// Why the sentinel: xterm parses queued writes asynchronously, so opening
|
||||
// the window at enqueue time would leak it over earlier queued unflagged
|
||||
// chunks (seed/hydration bytes parsing while depth > 0). Write callbacks
|
||||
// fire in FIFO parse order, so a zero-byte write whose callback opens the
|
||||
// window brackets the parse of exactly this chunk; the data callback
|
||||
// closes it.
|
||||
if (forwardQueryReplies) {
|
||||
this.terminal.write('', () => {
|
||||
this.queryReplyForwardingDepth += 1
|
||||
})
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.terminal.write(data, () => {
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth -= 1
|
||||
}
|
||||
// Why: snapshots combine serialized xterm state with mirrored mouse
|
||||
// modes. Commit the mirror only after xterm has parsed the same bytes.
|
||||
this.privateModes.scan(data)
|
||||
this.kittyKeyboardModes.scan(data)
|
||||
this.mouseModes.scan(data)
|
||||
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
|
||||
resolve()
|
||||
})
|
||||
@@ -117,31 +266,30 @@ export class HeadlessEmulator {
|
||||
return this.tryWriteSync(data)
|
||||
}
|
||||
|
||||
private tryWriteSync(data: string): boolean {
|
||||
private tryWriteSync(data: string, opts: HeadlessEmulatorWriteOptions = {}): boolean {
|
||||
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
|
||||
if (typeof writeSync !== 'function') {
|
||||
return false
|
||||
}
|
||||
this.scanInputForOscState(data)
|
||||
this.oscText.scan(data)
|
||||
const forwardQueryReplies = opts.forwardQueryReplies === true
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth += 1
|
||||
}
|
||||
// Why: hidden renderer restore snapshots are requested immediately after
|
||||
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
this.privateModes.scan(data)
|
||||
this.kittyKeyboardModes.scan(data)
|
||||
try {
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
} finally {
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth -= 1
|
||||
}
|
||||
}
|
||||
this.mouseModes.scan(data)
|
||||
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
|
||||
return true
|
||||
}
|
||||
|
||||
private scanInputForOscState(data: string): void {
|
||||
const oscInput = this.oscScanTail + data
|
||||
this.oscScanTail = this.extractOscScanTail(oscInput)
|
||||
this.scanOsc7(oscInput)
|
||||
const lastTitle = extractLastOscTitle(oscInput)
|
||||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -160,38 +308,65 @@ export class HeadlessEmulator {
|
||||
|
||||
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot {
|
||||
const modes = this.getModes()
|
||||
const snapshotAnsi = this.normalizeSnapshotAnsiForModes(
|
||||
this.serializer.serialize({ scrollback: opts.scrollbackRows }),
|
||||
modes
|
||||
// Why serializeWithAbsoluteCursor: SerializeAddon's relative cursor
|
||||
// restore lands one column short after a margin-filling final row leaves
|
||||
// replay wrap-pending; the trailing CUP survives the alt-marker slice.
|
||||
// The saved-cursor register rides along so a post-restore DECRC lands
|
||||
// where the hidden TUI saved, not at home.
|
||||
const serializedAnsi = serializeWithAbsoluteCursor(
|
||||
this.serializer,
|
||||
this.terminal,
|
||||
{ scrollback: opts.scrollbackRows },
|
||||
readSavedCursorRegister(this.terminal)
|
||||
)
|
||||
return {
|
||||
const { snapshotAnsi, scrollbackAnsi } = splitTerminalSnapshotAnsi(serializedAnsi, modes)
|
||||
const snapshot: TerminalSnapshot = {
|
||||
snapshotAnsi,
|
||||
scrollbackAnsi: '',
|
||||
scrollbackAnsi,
|
||||
oscLinks: collectHeadlessOscLinkRanges(
|
||||
this.terminal,
|
||||
opts.scrollbackRows,
|
||||
this.restoredOscLinks
|
||||
),
|
||||
rehydrateSequences: this.buildRehydrateSequences(modes),
|
||||
cwd: this.cwd,
|
||||
rehydrateSequences: buildRehydrateSequences(modes),
|
||||
cwd: this.oscText.cwd,
|
||||
modes,
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows,
|
||||
scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows,
|
||||
lastTitle: this.lastTitle ?? undefined,
|
||||
lastTitle: this.oscText.lastTitle ?? undefined,
|
||||
// Why: written LAST by the restorer (after any reset) so the next live
|
||||
// chunk completes this dangling sequence instead of rendering it literally
|
||||
// (#7329). Its bytes are already counted by the snapshot seq.
|
||||
// (Bug E / #7329). Its bytes are already counted by the snapshot seq.
|
||||
...(this.partialEscapeTail.length > 0
|
||||
? { pendingEscapeTailAnsi: this.partialEscapeTail }
|
||||
: {})
|
||||
}
|
||||
if (this.partialEscapeTail.length > 0) {
|
||||
// Why a separate field, not part of snapshotAnsi: consumers write their
|
||||
// own reset sequences after the snapshot body, and any ESC written after
|
||||
// a dangling partial would abort it. The restorer must write this LAST,
|
||||
// immediately before post-snapshot live chunks. Its bytes are already
|
||||
// counted by the snapshot seq (they were ingested), so tail-slicing
|
||||
// arithmetic is unchanged.
|
||||
snapshot.pendingEscapeTailAnsi = this.partialEscapeTail
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
get isAlternateScreen(): boolean {
|
||||
return this.terminal.buffer.active.type === 'alternate'
|
||||
}
|
||||
|
||||
/** The dangling incomplete escape at the current stream position (empty
|
||||
* when none). Scan-authority handoffs seed the other side's fact scanners
|
||||
* with it so a sequence split across the handoff neither mints a phantom
|
||||
* bell (unseen OSC terminator) nor loses its fact. Contains no complete
|
||||
* sequence by construction, so seeding can never double-fire. */
|
||||
get partialEscapeTailAnsi(): string {
|
||||
return this.partialEscapeTail
|
||||
}
|
||||
|
||||
/** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with
|
||||
* pending input it re-renders at a cached buffer row that ConPTY's fixed
|
||||
* viewport doesn't track, painting the input well below the prompt. The
|
||||
@@ -219,15 +394,15 @@ export class HeadlessEmulator {
|
||||
}
|
||||
|
||||
getCwd(): string | null {
|
||||
return this.cwd
|
||||
return this.oscText.cwd
|
||||
}
|
||||
|
||||
setCwd(cwd: string | null): void {
|
||||
this.cwd = cwd
|
||||
this.oscText.cwd = cwd
|
||||
}
|
||||
|
||||
setLastTitle(title: string): void {
|
||||
this.lastTitle = title
|
||||
this.oscText.lastTitle = title
|
||||
}
|
||||
|
||||
setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void {
|
||||
@@ -244,102 +419,25 @@ export class HeadlessEmulator {
|
||||
this.terminal.dispose()
|
||||
}
|
||||
|
||||
private scanOsc7(data: string): void {
|
||||
scanOsc7Uris(data, (uri) => {
|
||||
this.parseOsc7Uri(uri)
|
||||
})
|
||||
}
|
||||
|
||||
private extractOscScanTail(input: string): string {
|
||||
return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
|
||||
}
|
||||
|
||||
private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string {
|
||||
if (!modes.alternateScreen) {
|
||||
return snapshotAnsi
|
||||
}
|
||||
const alternateScreenMarker = '\x1b[?1049h'
|
||||
const start = snapshotAnsi.lastIndexOf(alternateScreenMarker)
|
||||
if (start === -1) {
|
||||
return snapshotAnsi
|
||||
}
|
||||
// Why: rehydrateSequences already enters the alternate screen and restores
|
||||
// mouse modes. Dropping SerializeAddon's duplicate ?1049h keeps mobile's
|
||||
// "slice from last alt-screen marker" replay from discarding those modes.
|
||||
return snapshotAnsi.slice(start + alternateScreenMarker.length)
|
||||
}
|
||||
|
||||
private parseOsc7Uri(uri: string): void {
|
||||
const parsed = parseFileUriPath(uri, {
|
||||
pathFlavor: this.pathFlavor,
|
||||
remotePosixAuthority: this.remotePosixFileUriAuthority
|
||||
})
|
||||
if (parsed) {
|
||||
this.cwd = parsed
|
||||
}
|
||||
}
|
||||
|
||||
private getModes(): TerminalModes {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const mouseTrackingMode = this.privateModes.mouseTrackingMode
|
||||
const mouseTrackingMode = this.mouseModes.mouseTrackingMode
|
||||
return {
|
||||
bracketedPaste: this.terminal.modes.bracketedPasteMode,
|
||||
mouseTracking: mouseTrackingMode !== 'none',
|
||||
mouseTrackingMode,
|
||||
sgrMouseMode: this.privateModes.sgrMouseMode,
|
||||
sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode,
|
||||
sgrMouseMode: this.mouseModes.sgrMouseMode,
|
||||
sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode,
|
||||
applicationCursor:
|
||||
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
|
||||
alternateScreen: buffer.type === 'alternate',
|
||||
kittyKeyboardFlags: this.kittyKeyboardModes.flags
|
||||
kittyKeyboardFlags: this.getKittyKeyboardFlags()
|
||||
}
|
||||
}
|
||||
|
||||
private buildRehydrateSequences(modes: TerminalModes): string {
|
||||
const seqs: string[] = []
|
||||
if (modes.alternateScreen) {
|
||||
seqs.push('\x1b[?1049h')
|
||||
}
|
||||
if (modes.bracketedPaste) {
|
||||
seqs.push('\x1b[?2004h')
|
||||
}
|
||||
if (modes.applicationCursor) {
|
||||
seqs.push('\x1b[?1h')
|
||||
}
|
||||
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
|
||||
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
|
||||
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
|
||||
case 'x10':
|
||||
seqs.push('\x1b[?9h')
|
||||
break
|
||||
case 'vt200':
|
||||
seqs.push('\x1b[?1000h')
|
||||
break
|
||||
case 'drag':
|
||||
seqs.push('\x1b[?1002h')
|
||||
break
|
||||
case 'any':
|
||||
seqs.push('\x1b[?1003h')
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
// Why: xterm tracks the mouse protocol and SGR encoding as independent
|
||||
// modes, so snapshots must preserve the encoding even when reporting is off.
|
||||
if (modes.sgrMousePixelsMode) {
|
||||
seqs.push('\x1b[?1016h')
|
||||
} else if (modes.sgrMouseMode) {
|
||||
seqs.push('\x1b[?1006h')
|
||||
}
|
||||
// Why: kitty keyboard flags are per-screen state SerializeAddon cannot
|
||||
// capture; without re-arming them, the still-running TUI keeps expecting
|
||||
// protocol-encoded keys the restored client no longer sends. `=` (set)
|
||||
// instead of `>` (push) so repeated replays cannot grow the flag stack.
|
||||
// Emitted after the alt-screen switch above so the flags land on the
|
||||
// screen the TUI negotiated them on.
|
||||
if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) {
|
||||
seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`)
|
||||
}
|
||||
return seqs.join('')
|
||||
private getKittyKeyboardFlags(): number {
|
||||
const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard
|
||||
?.flags
|
||||
return typeof flags === 'number' ? flags : 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,24 @@ export class HistoryManager {
|
||||
})
|
||||
}
|
||||
|
||||
// Why: wake after sleep re-spawns a session whose history was closed by the
|
||||
// sleep-time kill. Re-register the writer without deleting checkpoint.json
|
||||
// (still the only recovery data until the next tick) and clear endedAt so
|
||||
// the next sleep can cold-restore this session again.
|
||||
reopenSession(sessionId: string): void {
|
||||
this.disabledSessions.delete(sessionId)
|
||||
this.registerWriter(sessionId)
|
||||
const writer = this.writers.get(sessionId)
|
||||
if (!writer) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.updateMeta(writer.dir, { endedAt: null, exitCode: null })
|
||||
} catch (err) {
|
||||
this.handleWriteError(sessionId, err)
|
||||
}
|
||||
}
|
||||
|
||||
suspendSession(sessionId: string): void {
|
||||
// Why: if a fresh daemon cannot accept recovered scrollback, leaving its
|
||||
// writer active would let the next checkpoint overwrite the only good copy.
|
||||
|
||||
@@ -152,7 +152,13 @@ export class HistoryReader {
|
||||
})
|
||||
try {
|
||||
if (checkpoint) {
|
||||
if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) {
|
||||
if (
|
||||
!emulator.writeSync(
|
||||
(checkpoint.scrollbackAnsi ?? '') +
|
||||
checkpoint.rehydrateSequences +
|
||||
checkpoint.snapshotAnsi
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
emulator.setRestoredOscLinks(checkpoint.oscLinks)
|
||||
@@ -198,12 +204,8 @@ export class HistoryReader {
|
||||
cwd: string | null,
|
||||
meta: SessionMeta
|
||||
): ColdRestoreInfo {
|
||||
// Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi
|
||||
// (it's always ''). For non-alt-screen snapshots, snapshotAnsi IS the
|
||||
// normal buffer content and is safe to use as scrollback. For alt-screen
|
||||
// snapshots, snapshotAnsi is the serialized TUI buffer (not raw PTY
|
||||
// stream); return empty instead — the adapter skips cold restore when
|
||||
// scrollbackAnsi is falsy.
|
||||
// Why: legacy normal snapshots stored their buffer only in snapshotAnsi;
|
||||
// current alt snapshots carry their normal buffer in scrollbackAnsi.
|
||||
const scrollbackAnsi =
|
||||
snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi)
|
||||
return {
|
||||
|
||||
@@ -1068,6 +1068,30 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
dead = true
|
||||
}
|
||||
},
|
||||
// Why pause/resume work on Windows too: node-pty's base Terminal
|
||||
// implements both as socket pause/resume (lib/terminal.js), and
|
||||
// WindowsTerminal wires _socket to the ConPTY conout pipe — pausing stops
|
||||
// conout reads so ConPTY's bounded buffer backpressures the child.
|
||||
pause: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.pause()
|
||||
} catch {
|
||||
/* native handle already torn down — flow control is best-effort */
|
||||
}
|
||||
},
|
||||
resume: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.resume()
|
||||
} catch {
|
||||
/* native handle already torn down — flow control is best-effort */
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (dead) {
|
||||
return
|
||||
|
||||
@@ -37,7 +37,7 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
// for a reattach. Alt-screen sessions include the full ANSI snapshot because
|
||||
function buildReattachPayload(snapshot: ReturnType<HeadlessEmulator['getSnapshot']>) {
|
||||
const isAltScreen = snapshot.modes.alternateScreen
|
||||
const fullPayload = snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
const fullPayload = snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
return {
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
@@ -274,6 +274,24 @@ describe('reattach snapshot flow', () => {
|
||||
expect(payload.fullPayload).toContain('Codex TUI content')
|
||||
})
|
||||
|
||||
it('returns to preserved shell history after the reattached TUI exits', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 10 })
|
||||
await emulator.write('shell output before Codex\r\n$ codex')
|
||||
await emulator.write('\x1b[?1049h\x1b[2J\x1b[HCodex TUI content')
|
||||
const payload = buildReattachPayload(emulator.getSnapshot())
|
||||
emulator.dispose()
|
||||
|
||||
const replay = new HeadlessEmulator({ cols: 80, rows: 10 })
|
||||
try {
|
||||
await replay.write(payload.fullPayload)
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('Codex TUI content')
|
||||
await replay.write('\x1b[?1049l')
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('shell output before Codex')
|
||||
} finally {
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('SIGWINCH repaint after rehydrate produces clean single render', async () => {
|
||||
const h = createHost()
|
||||
await h.createOrAttach({
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { Session, type SubprocessHandle } from './session'
|
||||
|
||||
// Benchmark harness for the terminal performance initiative: measures the
|
||||
// daemon-side ingest rate (Session.handleSubprocessData -> HeadlessEmulator
|
||||
// write + pending-output recording + client fanout) for the same workload
|
||||
// shapes as tools/benchmarks/terminal-pipeline-bench.mjs. Bare headless
|
||||
// xterm parses these at ~80-100 MB/s; the end-to-end Orca pipeline measured
|
||||
// 2-15 MB/s (baseline-jul02) — this isolates the daemon layer's share.
|
||||
// Run with:
|
||||
// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \
|
||||
// src/main/daemon/session-ingest-throughput.bench.test.ts \
|
||||
// --config config/vitest.config.ts
|
||||
const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1'
|
||||
|
||||
const COLS = 114
|
||||
const ROWS = 85
|
||||
const TARGET_BYTES = 10 * 1024 * 1024
|
||||
const CHUNK = 64 * 1024
|
||||
|
||||
function asciiLog(targetBytes: number): string {
|
||||
const parts: string[] = []
|
||||
let bytes = 0
|
||||
let line = 0
|
||||
while (bytes < targetBytes) {
|
||||
line++
|
||||
const s = `\x1b[32m[build ${String(line).padStart(6, '0')}]\x1b[0m compile transform resolve bundle emit chunk module (${line % 5000}ms)\r\n`
|
||||
parts.push(s)
|
||||
bytes += s.length
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function agentTui(targetBytes: number): string {
|
||||
const statusRows = 10
|
||||
const parts: string[] = []
|
||||
let bytes = 0
|
||||
let frame = 0
|
||||
let painted = false
|
||||
const push = (s: string): void => {
|
||||
parts.push(s)
|
||||
bytes += Buffer.byteLength(s, 'utf8')
|
||||
}
|
||||
while (bytes < targetBytes) {
|
||||
frame++
|
||||
push('\x1b[?2026h')
|
||||
if (painted) {
|
||||
push(`\x1b[${statusRows}A\x1b[0J`)
|
||||
}
|
||||
push(`\x1b[2m●\x1b[0m transcript line for frame ${frame} with some words\r\n`)
|
||||
for (let r = 0; r < statusRows; r++) {
|
||||
push(
|
||||
`\x1b[38;5;${33 + (r % 6)}m⠼ task ${frame % 100}·${r}\x1b[0m ${'▇'.repeat((frame + r) % 40)}\r\n`
|
||||
)
|
||||
}
|
||||
painted = true
|
||||
push('\x1b[?2026l')
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function makeSubprocess(): SubprocessHandle & { emit: (data: string) => void } {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: () => 'bench',
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
kill: () => {},
|
||||
forceKill: () => {},
|
||||
signal: () => {},
|
||||
onData: (cb) => {
|
||||
onData = cb
|
||||
},
|
||||
onExit: () => {},
|
||||
dispose: () => {},
|
||||
emit: (data: string) => onData?.(data)
|
||||
}
|
||||
}
|
||||
|
||||
function ingest(fixture: string, drainPendingEveryChunks: number | null): number {
|
||||
const subprocess = makeSubprocess()
|
||||
const session = new Session({
|
||||
sessionId: 'bench',
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
subprocess,
|
||||
shellReadySupported: false
|
||||
})
|
||||
session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
// Warmup primes JIT paths.
|
||||
subprocess.emit(fixture.slice(0, 256 * 1024))
|
||||
session.takePendingOutput(false)
|
||||
const start = performance.now()
|
||||
let chunks = 0
|
||||
for (let i = 0; i < fixture.length; i += CHUNK) {
|
||||
subprocess.emit(fixture.slice(i, i + CHUNK))
|
||||
chunks++
|
||||
// Why: without periodic takes the 2MB pending cap overflows and recording
|
||||
// short-circuits, understating the real steady-state cost. The 5s adapter
|
||||
// tick drains in production; drain per ~1.5MB approximates a hot session.
|
||||
if (drainPendingEveryChunks && chunks % drainPendingEveryChunks === 0) {
|
||||
session.takePendingOutput(false)
|
||||
}
|
||||
}
|
||||
const ms = performance.now() - start
|
||||
session.dispose()
|
||||
return ms
|
||||
}
|
||||
|
||||
describe.skipIf(!benchEnabled)('daemon session ingest throughput', () => {
|
||||
it('measures MB/s per workload shape', () => {
|
||||
const rows: string[] = []
|
||||
for (const [name, fixture] of [
|
||||
['ascii-log', asciiLog(TARGET_BYTES)],
|
||||
['agent-tui', agentTui(TARGET_BYTES)]
|
||||
] as const) {
|
||||
const bytes = Buffer.byteLength(fixture, 'utf8')
|
||||
const ms = ingest(fixture, 24)
|
||||
const rate = bytes / 1024 / 1024 / (ms / 1000)
|
||||
rows.push(
|
||||
`${name}: ${rate.toFixed(1)} MB/s (${ms.toFixed(0)}ms for ${(bytes / 1024 / 1024).toFixed(1)}MB)`
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line no-console -- bench harness output
|
||||
console.log(`\n[session-ingest] ${COLS}x${ROWS}\n ${rows.join('\n ')}`)
|
||||
expect(rows.length).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Session } from './session'
|
||||
import { PRODUCER_PAUSE_FAILSAFE_MS, Session } from './session'
|
||||
import type { SessionState, ShellReadyState } from './types'
|
||||
|
||||
// Stub the subprocess — Session talks to it via an interface, not child_process directly.
|
||||
@@ -11,6 +11,8 @@ function createMockSubprocess() {
|
||||
let killed = false
|
||||
let clearCalls = 0
|
||||
let pid = 12345
|
||||
let pauseCalls = 0
|
||||
let resumeCalls = 0
|
||||
|
||||
return {
|
||||
written,
|
||||
@@ -21,6 +23,12 @@ function createMockSubprocess() {
|
||||
get pid() {
|
||||
return pid
|
||||
},
|
||||
get pauseCalls() {
|
||||
return pauseCalls
|
||||
},
|
||||
get resumeCalls() {
|
||||
return resumeCalls
|
||||
},
|
||||
foregroundProcess: null as string | null,
|
||||
getForegroundProcess(): string | null {
|
||||
return this.foregroundProcess
|
||||
@@ -29,6 +37,12 @@ function createMockSubprocess() {
|
||||
written.push(data)
|
||||
},
|
||||
resize(_cols: number, _rows: number) {},
|
||||
pause() {
|
||||
pauseCalls++
|
||||
},
|
||||
resume() {
|
||||
resumeCalls++
|
||||
},
|
||||
get clearCalls() {
|
||||
return clearCalls
|
||||
},
|
||||
@@ -174,14 +188,22 @@ describe('Session', () => {
|
||||
|
||||
describe('emulator does not reply to terminal queries', () => {
|
||||
// Why: daemon emulator parses in-process synchronously — before
|
||||
// handleSubprocessData forwards bytes to the renderer over IPC — so any
|
||||
// auto-reply it emits races ahead of the renderer's xterm and clobbers
|
||||
// it with default-xterm values (no theme, stale cursor). The renderer is
|
||||
// the authoritative responder; a daemon-side reply to any query is a bug.
|
||||
// handleSubprocessData forwards bytes onward — so any auto-reply it
|
||||
// emits races ahead of the live answerer and clobbers it with
|
||||
// default-xterm values (no theme, stale cursor). Query authority is
|
||||
// structural (terminal-query-authority.md): a delivered chunk is
|
||||
// answered by the consuming view's xterm, a hidden-dropped chunk by
|
||||
// MAIN's runtime model responder. The daemon emulator is neither — it
|
||||
// stays write-only forever, and these pins are permanent.
|
||||
it.each([
|
||||
['OSC 10 foreground-color', '\x1b]10;?\x07'],
|
||||
['OSC 11 background-color', '\x1b]11;?\x07'],
|
||||
['OSC 12 cursor-color', '\x1b]12;?\x1b\\'],
|
||||
['DA1 device-attributes', '\x1b[c'],
|
||||
['DSR cursor-position', '\x1b[6n']
|
||||
['DA2 secondary device-attributes', '\x1b[>c'],
|
||||
['DSR terminal status', '\x1b[5n'],
|
||||
['DSR cursor-position', '\x1b[6n'],
|
||||
['DECRPM bracketed-paste mode', '\x1b[?2004$p']
|
||||
])('does not reply to %s query', async (_label, query) => {
|
||||
createSession({ shellReadySupported: false })
|
||||
subprocess.simulateData(query)
|
||||
@@ -260,6 +282,15 @@ describe('Session', () => {
|
||||
expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready')
|
||||
})
|
||||
|
||||
it('publishes an absolute output sequence with live snapshots', () => {
|
||||
createSession()
|
||||
subprocess.simulateData('first')
|
||||
subprocess.simulateData('🟢second')
|
||||
|
||||
expect(session.getSnapshot()?.outputSequence).toBe('first🟢second'.length)
|
||||
expect(session.takePendingOutput(true)?.snapshot?.outputSequence).toBe('first🟢second'.length)
|
||||
})
|
||||
|
||||
it('releases held marker-prefix bytes before flushing queued input on timeout', () => {
|
||||
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
|
||||
const received: string[] = []
|
||||
@@ -600,4 +631,103 @@ describe('Session', () => {
|
||||
expect(session.state).toBe('exited')
|
||||
})
|
||||
})
|
||||
|
||||
describe('producer flow control', () => {
|
||||
it('pauses the subprocess and auto-resumes via the lost-resume failsafe', () => {
|
||||
createSession()
|
||||
session.pauseProducer()
|
||||
expect(subprocess.pauseCalls).toBe(1)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('resumeProducer resumes once and cancels the failsafe timer', () => {
|
||||
createSession()
|
||||
session.pauseProducer()
|
||||
session.resumeProducer()
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('resumeProducer without a matching pause is a no-op', () => {
|
||||
createSession()
|
||||
session.resumeProducer()
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('re-pausing re-arms the failsafe window', () => {
|
||||
createSession()
|
||||
session.pauseProducer()
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1_000)
|
||||
session.pauseProducer()
|
||||
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('kill() resumes a paused producer before signalling the child', () => {
|
||||
createSession()
|
||||
session.pauseProducer()
|
||||
session.kill()
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
expect(subprocess.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('dispose() resumes a paused producer and clears the failsafe', () => {
|
||||
createSession()
|
||||
session.pauseProducer()
|
||||
session.dispose()
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('subprocess exit clears the failsafe without resuming a reaped child', () => {
|
||||
createSession()
|
||||
session.pauseProducer()
|
||||
subprocess.simulateExit(0)
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores pauseProducer on an exited session', () => {
|
||||
createSession()
|
||||
subprocess.simulateExit(0)
|
||||
session.pauseProducer()
|
||||
expect(subprocess.pauseCalls).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('detaching the last client resumes a paused producer', () => {
|
||||
createSession()
|
||||
const token = session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
session.pauseProducer()
|
||||
session.detachClient(token)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the pause while another client is still attached', () => {
|
||||
createSession()
|
||||
const token = session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
session.pauseProducer()
|
||||
session.detachClient(token)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('detachAllClients resumes a paused producer', () => {
|
||||
createSession()
|
||||
session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
session.pauseProducer()
|
||||
session.detachAllClients()
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,11 @@ const KILL_TIMEOUT_MS = 5_000
|
||||
// Worst-case wire size for a full take is ~6x this (each control char
|
||||
// JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB).
|
||||
const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
|
||||
// Why: producer pause is requested over a fire-and-forget notification, so the
|
||||
// matching resume can be lost (main crash, dropped socket). A lost resume must
|
||||
// never wedge a shell: auto-resume after this window; a still-flooded main
|
||||
// re-asserts the pause on its next watermark check.
|
||||
export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000
|
||||
|
||||
export type SubprocessHandle = {
|
||||
pid: number
|
||||
@@ -42,6 +47,11 @@ export type SubprocessHandle = {
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
/** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer
|
||||
* fills and a flooding child blocks on write. Optional: handles that
|
||||
* cannot pause simply omit it and flow control degrades to a no-op. */
|
||||
pause?(): void
|
||||
resume?(): void
|
||||
/** Resync the native PTY's own screen state after a frontend clear.
|
||||
* No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
|
||||
* the next prompt repaint land below a blank gap. */
|
||||
@@ -102,6 +112,9 @@ export class Session {
|
||||
private pendingOutputBytes = 0
|
||||
private pendingOutputOverflowed = false
|
||||
private pendingOutputSeq = 0
|
||||
private outputSequence = 0
|
||||
private producerPaused = false
|
||||
private producerPauseFailsafeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private readonly _historySeeded: boolean | undefined
|
||||
|
||||
constructor(opts: SessionOptions) {
|
||||
@@ -198,12 +211,52 @@ export class Session {
|
||||
this.subprocess.resize(cols, rows)
|
||||
}
|
||||
|
||||
/** Producer-side flow control: stop reading the PTY fd so the flooding
|
||||
* child blocks on write (kernel backpressure). Arms the lost-resume
|
||||
* failsafe; re-pausing re-arms it (main re-asserts during long floods). */
|
||||
pauseProducer(): void {
|
||||
if (this._state === 'exited' || this._disposed) {
|
||||
return
|
||||
}
|
||||
this.producerPaused = true
|
||||
this.subprocess.pause?.()
|
||||
if (this.producerPauseFailsafeTimer) {
|
||||
clearTimeout(this.producerPauseFailsafeTimer)
|
||||
}
|
||||
this.producerPauseFailsafeTimer = setTimeout(() => {
|
||||
this.producerPauseFailsafeTimer = null
|
||||
this.producerPaused = false
|
||||
this.subprocess.resume?.()
|
||||
}, PRODUCER_PAUSE_FAILSAFE_MS)
|
||||
}
|
||||
|
||||
resumeProducer(): void {
|
||||
this.releaseProducerPause({ resume: true })
|
||||
}
|
||||
|
||||
private releaseProducerPause(opts: { resume: boolean }): void {
|
||||
if (this.producerPauseFailsafeTimer) {
|
||||
clearTimeout(this.producerPauseFailsafeTimer)
|
||||
this.producerPauseFailsafeTimer = null
|
||||
}
|
||||
if (!this.producerPaused) {
|
||||
return
|
||||
}
|
||||
this.producerPaused = false
|
||||
if (opts.resume) {
|
||||
this.subprocess.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
if (this._state === 'exited' || this._isTerminating) {
|
||||
return
|
||||
}
|
||||
this._isTerminating = true
|
||||
|
||||
// Why: a paused child can be blocked inside write(); resume before
|
||||
// signalling so it can run signal handlers and actually exit.
|
||||
this.releaseProducerPause({ resume: true })
|
||||
this.subprocess.kill()
|
||||
|
||||
this.killTimer = setTimeout(() => {
|
||||
@@ -231,17 +284,30 @@ export class Session {
|
||||
if (idx !== -1) {
|
||||
this.attachedClients.splice(idx, 1)
|
||||
}
|
||||
// Why: with no attached client, nobody will ever send resumePty — a
|
||||
// paused shell would sit wedged until the failsafe. Resume eagerly.
|
||||
if (this.attachedClients.length === 0) {
|
||||
this.releaseProducerPause({ resume: true })
|
||||
}
|
||||
}
|
||||
|
||||
detachAllClients(): void {
|
||||
this.attachedClients.length = 0
|
||||
this.releaseProducerPause({ resume: true })
|
||||
}
|
||||
|
||||
getSnapshot(): TerminalSnapshot | null {
|
||||
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||
if (this._disposed) {
|
||||
return null
|
||||
}
|
||||
return this.emulator.getSnapshot()
|
||||
return { ...this.emulator.getSnapshot(opts), outputSequence: this.outputSequence }
|
||||
}
|
||||
|
||||
getPartialEscapeTailAnsi(): string {
|
||||
if (this._disposed) {
|
||||
return ''
|
||||
}
|
||||
return this.emulator.partialEscapeTailAnsi
|
||||
}
|
||||
|
||||
// Why: the size the PTY actually applied (emulator dims, which Session.resize
|
||||
@@ -282,7 +348,7 @@ export class Session {
|
||||
: records,
|
||||
seq: this.pendingOutputSeq,
|
||||
overflowed,
|
||||
snapshot: includeSnapshot ? this.emulator.getSnapshot() : null
|
||||
snapshot: includeSnapshot ? this.getSnapshot() : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +490,9 @@ export class Session {
|
||||
return
|
||||
}
|
||||
this._disposed = true
|
||||
// Why: never leave a paused fd behind on any teardown path — the handle's
|
||||
// own dead-guard makes this a no-op when the child is already reaped.
|
||||
this.releaseProducerPause({ resume: true })
|
||||
if (this.killTimer) {
|
||||
clearTimeout(this.killTimer)
|
||||
this.killTimer = null
|
||||
@@ -490,6 +559,10 @@ export class Session {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: daemon stream thinning can omit bytes before main sees them. The
|
||||
// absolute count lets an authoritative snapshot cover those gaps while
|
||||
// renderer reconciliation deduplicates any queued post-snapshot tail.
|
||||
this.outputSequence += data.length
|
||||
// Feed data to headless emulator for state tracking
|
||||
this.emulator.write(data)
|
||||
this.recordPendingOutput({ kind: 'output', data })
|
||||
@@ -507,6 +580,9 @@ export class Session {
|
||||
|
||||
this._exitCode = code
|
||||
this._state = 'exited'
|
||||
// Why resume:false — the child is reaped, so there is nothing to unblock;
|
||||
// only the failsafe timer must not outlive the session.
|
||||
this.releaseProducerPause({ resume: false })
|
||||
this.releaseHeldShellReadyBytes()
|
||||
|
||||
if (this.killTimer) {
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('incremental terminal history restore', () => {
|
||||
expect(restore!.scrollbackAnsi).not.toContain('cleared away')
|
||||
})
|
||||
|
||||
it('skips restorable content for sessions crashed inside the alt screen', async () => {
|
||||
it('preserves normal history without treating active alt content as scrollback', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' }
|
||||
])
|
||||
@@ -191,9 +191,8 @@ describe('incremental terminal history restore', () => {
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.modes.alternateScreen).toBe(true)
|
||||
// Why: the adapter skips cold restore when scrollbackAnsi is empty — alt
|
||||
// buffer contents must not replay into a fresh shell.
|
||||
expect(restore!.scrollbackAnsi).toBe('')
|
||||
expect(restore!.scrollbackAnsi).toContain('normal output')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('alt screen content')
|
||||
})
|
||||
|
||||
it('resets the log on checkpoint so old records are not replayed twice', async () => {
|
||||
|
||||
@@ -197,6 +197,21 @@ export class TerminalHost {
|
||||
this.getAliveSession(sessionId).resize(cols, rows)
|
||||
}
|
||||
|
||||
// Why null-not-throw (unlike write/resize): pause/resume are best-effort
|
||||
// flow-control hints; a session that exited while the notify was in flight
|
||||
// must not surface an error or a synthetic exit.
|
||||
pauseProducer(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return
|
||||
}
|
||||
session.pauseProducer()
|
||||
}
|
||||
|
||||
resumeProducer(sessionId: string): void {
|
||||
this.sessions.get(sessionId)?.resumeProducer()
|
||||
}
|
||||
|
||||
kill(sessionId: string, opts: { immediate?: boolean } = {}): void {
|
||||
const session = this.getAliveSession(sessionId)
|
||||
this.recordTombstone(sessionId)
|
||||
@@ -266,12 +281,22 @@ export class TerminalHost {
|
||||
// Why: unlike getAliveSession (which throws), this returns null for dead/missing
|
||||
// sessions. Checkpoint is best-effort — a session that exited between the timer
|
||||
// firing and the RPC arriving should not throw.
|
||||
getSnapshot(sessionId: string): TerminalSnapshot | null {
|
||||
getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.getSnapshot()
|
||||
return session.getSnapshot(opts)
|
||||
}
|
||||
|
||||
// Why: scan-authority handoff seed (null-not-throw like getSnapshot) — the
|
||||
// emulator's dangling incomplete escape at the current stream position.
|
||||
getPartialEscapeTailAnsi(sessionId: string): string {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return ''
|
||||
}
|
||||
return session.getPartialEscapeTailAnsi()
|
||||
}
|
||||
|
||||
// Why: read-only readback of the size the PTY actually applied (null-not-throw
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
|
||||
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
|
||||
// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for
|
||||
// emulator re-seed parity only; a re-seeded emulator answers ?0u and
|
||||
// protocol-conformant programs re-push.
|
||||
export function buildRehydrateSequences(modes: TerminalModes): string {
|
||||
const seqs: string[] = []
|
||||
if (modes.alternateScreen) {
|
||||
// Why: normal-buffer serialization can leave its pen active, while the
|
||||
// separately serialized alt body assumes it starts from default SGR.
|
||||
seqs.push('\x1b[0m\x1b[?1049h')
|
||||
}
|
||||
if (modes.bracketedPaste) {
|
||||
seqs.push('\x1b[?2004h')
|
||||
}
|
||||
if (modes.applicationCursor) {
|
||||
seqs.push('\x1b[?1h')
|
||||
}
|
||||
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
|
||||
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
|
||||
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
|
||||
case 'x10':
|
||||
seqs.push('\x1b[?9h')
|
||||
break
|
||||
case 'vt200':
|
||||
seqs.push('\x1b[?1000h')
|
||||
break
|
||||
case 'drag':
|
||||
seqs.push('\x1b[?1002h')
|
||||
break
|
||||
case 'any':
|
||||
seqs.push('\x1b[?1003h')
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
// Why: xterm tracks the mouse protocol and SGR encoding as independent
|
||||
// modes, so snapshots must preserve the encoding even when reporting is off.
|
||||
if (modes.sgrMousePixelsMode) {
|
||||
seqs.push('\x1b[?1016h')
|
||||
} else if (modes.sgrMouseMode) {
|
||||
seqs.push('\x1b[?1006h')
|
||||
}
|
||||
return seqs.join('')
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
|
||||
|
||||
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
|
||||
// Keep parser state far beyond normal mode lists while still bounding memory.
|
||||
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* Mirrors DECSET mouse-protocol/encoding state from the raw byte stream.
|
||||
* xterm's public modes API does not expose which mouse protocol is active,
|
||||
* so snapshots track it independently of the headless terminal; callers
|
||||
* must feed `scan()` the same bytes the terminal parsed, in order.
|
||||
*/
|
||||
export class TerminalMouseModeMirror {
|
||||
private scanTail = ''
|
||||
private trackingModeState: MouseTrackingMode = 'none'
|
||||
private sgrMouseModeState = false
|
||||
private sgrMousePixelsModeState = false
|
||||
|
||||
get mouseTrackingMode(): MouseTrackingMode {
|
||||
return this.trackingModeState
|
||||
}
|
||||
|
||||
get sgrMouseMode(): boolean {
|
||||
return this.sgrMouseModeState
|
||||
}
|
||||
|
||||
get sgrMousePixelsMode(): boolean {
|
||||
return this.sgrMousePixelsModeState
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
// Why the pre-filter: this runs on the daemon's per-chunk hot path for
|
||||
// every session; a flood chunk with no private-mode/reset introducer
|
||||
// must not pay the regex pass (measured share of a 2.2x ingest
|
||||
// regression — findings log 2026-07-03). Split sequences stay correct:
|
||||
// an introducer split across chunks either left a non-empty scanTail
|
||||
// (previous partial) or ends this chunk, which extractScanTail retains.
|
||||
if (
|
||||
this.scanTail.length === 0 &&
|
||||
!data.includes('\x1b[?') &&
|
||||
!data.includes('\x1bc') &&
|
||||
!data.includes('\x9b')
|
||||
) {
|
||||
this.scanTail = this.extractScanTail(data)
|
||||
return
|
||||
}
|
||||
const input = this.scanTail.length === 0 ? data : this.scanTail + data
|
||||
this.scanTail = this.extractScanTail(input)
|
||||
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
|
||||
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = privateModeRe.exec(input)) !== null) {
|
||||
if (match[0] === '\x1bc') {
|
||||
this.trackingModeState = 'none'
|
||||
this.sgrMouseModeState = false
|
||||
this.sgrMousePixelsModeState = false
|
||||
continue
|
||||
}
|
||||
const params = match[1] ?? match[3]
|
||||
const enabled = (match[2] ?? match[4]) === 'h'
|
||||
for (const rawParam of params.split(';')) {
|
||||
if (rawParam === '') {
|
||||
continue
|
||||
}
|
||||
const param = Number(rawParam)
|
||||
if (!Number.isInteger(param)) {
|
||||
continue
|
||||
}
|
||||
if (param === 9) {
|
||||
this.trackingModeState = enabled ? 'x10' : 'none'
|
||||
}
|
||||
if (param === 1000) {
|
||||
this.trackingModeState = enabled ? 'vt200' : 'none'
|
||||
}
|
||||
if (param === 1002) {
|
||||
this.trackingModeState = enabled ? 'drag' : 'none'
|
||||
}
|
||||
if (param === 1003) {
|
||||
this.trackingModeState = enabled ? 'any' : 'none'
|
||||
}
|
||||
if (param === 1006) {
|
||||
this.sgrMouseModeState = enabled
|
||||
this.sgrMousePixelsModeState = false
|
||||
}
|
||||
if (param === 1016) {
|
||||
this.sgrMouseModeState = false
|
||||
this.sgrMousePixelsModeState = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractScanTail(input: string): string {
|
||||
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
|
||||
if (start === -1) {
|
||||
return ''
|
||||
}
|
||||
const tail = input.slice(start)
|
||||
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
|
||||
return ''
|
||||
}
|
||||
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
|
||||
return tail
|
||||
}
|
||||
if (tail.startsWith('\x1b[?')) {
|
||||
return this.isIncompleteParams(tail.slice(3)) ? tail : ''
|
||||
}
|
||||
if (tail.startsWith('\x9b?')) {
|
||||
return this.isIncompleteParams(tail.slice(2)) ? tail : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private isIncompleteParams(params: string): boolean {
|
||||
return /^[0-9;]*$/.test(params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { extractLastOscTitle } from '../../shared/agent-detection'
|
||||
import { parseFileUriPath } from './osc7-file-uri'
|
||||
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
|
||||
|
||||
const OSC_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
/** Mirror of the OSC sequences the emulator tracks outside xterm: OSC 7 cwd
|
||||
* updates and OSC 0/2 titles. Keeps an unterminated-sequence tail so
|
||||
* sequences split across PTY chunks still parse. Uses the bounded regex-free
|
||||
* scanners so giant pasted chunks stay cheap. */
|
||||
export type TerminalOscCwdTitleScannerOptions = {
|
||||
pathFlavor?: 'posix' | 'win32'
|
||||
remotePosixAuthority?: boolean
|
||||
}
|
||||
|
||||
export class TerminalOscCwdTitleScanner {
|
||||
private scanTail = ''
|
||||
private readonly parseOptions: TerminalOscCwdTitleScannerOptions
|
||||
cwd: string | null = null
|
||||
lastTitle: string | null = null
|
||||
|
||||
constructor(options: TerminalOscCwdTitleScannerOptions = {}) {
|
||||
this.parseOptions = options
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
// Why the pre-filter: this runs on the daemon's per-chunk hot path; flood
|
||||
// chunks with no OSC introducer must not pay the title/URI walks
|
||||
// (measured share of a 2.2x ingest regression — findings log 2026-07-03).
|
||||
// Correctness across splits: an OSC intro spanning chunks either left a
|
||||
// non-empty scanTail or this chunk ends with a bare ESC, which
|
||||
// extractOscScanTail retains for the next call.
|
||||
if (this.scanTail.length === 0 && !data.includes('\x1b]')) {
|
||||
this.scanTail = data.endsWith('\x1b') ? extractOscScanTail(data, OSC_SCAN_TAIL_LIMIT) : ''
|
||||
return
|
||||
}
|
||||
const input = this.scanTail.length === 0 ? data : this.scanTail + data
|
||||
this.scanTail = extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
|
||||
scanOsc7Uris(input, (uri) => {
|
||||
const parsed = parseFileUriPath(uri, {
|
||||
pathFlavor: this.parseOptions.pathFlavor,
|
||||
remotePosixAuthority: this.parseOptions.remotePosixAuthority
|
||||
})
|
||||
if (parsed) {
|
||||
this.cwd = parsed
|
||||
}
|
||||
})
|
||||
const lastTitle = extractLastOscTitle(input)
|
||||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
export function splitTerminalSnapshotAnsi(
|
||||
snapshotAnsi: string,
|
||||
modes: TerminalModes
|
||||
): { snapshotAnsi: string; scrollbackAnsi: string } {
|
||||
if (!modes.alternateScreen) {
|
||||
return { snapshotAnsi, scrollbackAnsi: '' }
|
||||
}
|
||||
const alternateScreenMarker = '\x1b[?1049h'
|
||||
const start = snapshotAnsi.lastIndexOf(alternateScreenMarker)
|
||||
if (start === -1) {
|
||||
return { snapshotAnsi, scrollbackAnsi: '' }
|
||||
}
|
||||
// Why: rehydrateSequences owns the alt-screen transition. Keeping the
|
||||
// normal buffer separate lets an already-alt renderer rebuild it safely.
|
||||
return {
|
||||
scrollbackAnsi: snapshotAnsi.slice(0, start),
|
||||
snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Round-trip guards for two @xterm/addon-serialize defects that garbled
|
||||
// hidden-terminal snapshot restores (serialize a buffer, replay into a fresh
|
||||
// identical terminal, compare):
|
||||
//
|
||||
// BUG B (fixed by config/patches/@xterm__addon-serialize@*.patch): the SGR
|
||||
// attribute diff emitted bold/dim set params before the shared intensity
|
||||
// reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a
|
||||
// still-set bold/dim.
|
||||
//
|
||||
// BUG C (hardened Orca-side via serializeWithAbsoluteCursor): a final content
|
||||
// row filled exactly to the right margin leaves replay wrap-pending, and the
|
||||
// addon's RELATIVE cursor restore then lands one column short.
|
||||
import './xterm-env-polyfill'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor'
|
||||
|
||||
type TerminalHarness = { terminal: Terminal; addon: SerializeAddon }
|
||||
|
||||
function createTerminal(cols = 10, rows = 5, scrollback = 100): TerminalHarness {
|
||||
const terminal = new Terminal({ cols, rows, scrollback, allowProposedApi: true })
|
||||
const addon = new SerializeAddon()
|
||||
terminal.loadAddon(addon)
|
||||
return { terminal, addon }
|
||||
}
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, () => resolve()))
|
||||
}
|
||||
|
||||
async function replay(data: string, cols = 10, rows = 5, scrollback = 100): Promise<Terminal> {
|
||||
const { terminal } = createTerminal(cols, rows, scrollback)
|
||||
await write(terminal, data)
|
||||
return terminal
|
||||
}
|
||||
|
||||
function cellAt(
|
||||
terminal: Terminal,
|
||||
viewportRow: number,
|
||||
col: number
|
||||
): NonNullable<
|
||||
ReturnType<NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>['getCell']>
|
||||
> {
|
||||
const buffer = terminal.buffer.active
|
||||
const line = buffer.getLine(buffer.baseY + viewportRow)
|
||||
if (!line) {
|
||||
throw new Error(`no line at viewport row ${viewportRow}`)
|
||||
}
|
||||
const cell = line.getCell(col)
|
||||
if (!cell) {
|
||||
throw new Error(`no cell at ${viewportRow},${col}`)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
function visibleText(terminal: Terminal): string[] {
|
||||
const buffer = terminal.buffer.active
|
||||
const lines: string[] = []
|
||||
for (let row = 0; row < terminal.rows; row += 1) {
|
||||
lines.push(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
async function roundTripStyles(source: string): Promise<Terminal> {
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, source)
|
||||
return replay(addon.serialize())
|
||||
}
|
||||
|
||||
describe('SGR intensity round-trip (BUG B, addon patch)', () => {
|
||||
it('restores bold set immediately after dim is cleared (minimized repro)', async () => {
|
||||
const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB')
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(b.getChars()).toBe('B')
|
||||
expect(!!b.isBold()).toBe(true)
|
||||
expect(!!b.isDim()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps bold when dim is dropped from a bold+dim run', async () => {
|
||||
const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[1mB')
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(!!b.isBold()).toBe(true)
|
||||
expect(!!b.isDim()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps dim when bold is dropped from a bold+dim run', async () => {
|
||||
const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[2mB')
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(!!b.isDim()).toBe(true)
|
||||
expect(!!b.isBold()).toBe(false)
|
||||
})
|
||||
|
||||
it('non-regression: bold after normal text', async () => {
|
||||
const restored = await roundTripStyles('A\x1b[1mB')
|
||||
expect(!!cellAt(restored, 0, 0).isBold()).toBe(false)
|
||||
expect(!!cellAt(restored, 0, 1).isBold()).toBe(true)
|
||||
})
|
||||
|
||||
it('non-regression: dim after bold', async () => {
|
||||
const restored = await roundTripStyles('\x1b[1mA\x1b[22m\x1b[2mB')
|
||||
const a = cellAt(restored, 0, 0)
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(!!a.isBold()).toBe(true)
|
||||
expect(!!a.isDim()).toBe(false)
|
||||
expect(!!b.isDim()).toBe(true)
|
||||
expect(!!b.isBold()).toBe(false)
|
||||
})
|
||||
|
||||
it('non-regression: bold+dim accumulation survives', async () => {
|
||||
const restored = await roundTripStyles('\x1b[1mA\x1b[2mB')
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(!!b.isBold()).toBe(true)
|
||||
expect(!!b.isDim()).toBe(true)
|
||||
})
|
||||
|
||||
it('non-regression: italic after underline is cleared (dedicated resets)', async () => {
|
||||
const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[3mB')
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(!!b.isItalic()).toBe(true)
|
||||
expect(!!b.isUnderline()).toBe(false)
|
||||
})
|
||||
|
||||
it('non-regression: underline after italic is cleared (dedicated resets)', async () => {
|
||||
const restored = await roundTripStyles('\x1b[3mA\x1b[23m\x1b[4mB')
|
||||
const b = cellAt(restored, 0, 1)
|
||||
expect(!!b.isUnderline()).toBe(true)
|
||||
expect(!!b.isItalic()).toBe(false)
|
||||
})
|
||||
|
||||
it('non-regression: underline dropped alongside bold set', async () => {
|
||||
const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[1;4mB\x1b[24mC')
|
||||
const c = cellAt(restored, 0, 2)
|
||||
expect(!!c.isBold()).toBe(true)
|
||||
expect(!!c.isUnderline()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor restore after wrap-pending replay (BUG C, absolute-cursor hardening)', () => {
|
||||
const REPRO = '0123456789\x1b[3;5H'
|
||||
|
||||
it('documents the upstream defect: plain serialize lands one column short', async () => {
|
||||
// Why this pin: the Orca hardening exists only because of this relative-
|
||||
// restore defect. If an addon bump makes this fail, the hardening can go.
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
await write(terminal, REPRO)
|
||||
expect(terminal.buffer.active.cursorX).toBe(4)
|
||||
const restored = await replay(addon.serialize())
|
||||
expect(restored.buffer.active.cursorX).toBe(3)
|
||||
})
|
||||
|
||||
it('HeadlessEmulator snapshot restores the exact cursor (minimized repro)', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 10, rows: 5 })
|
||||
expect(emulator.writeSync(REPRO)).toBe(true)
|
||||
const snapshot = emulator.getSnapshot()
|
||||
const restored = await replay(snapshot.snapshotAnsi)
|
||||
expect(restored.buffer.active.cursorX).toBe(4)
|
||||
expect(restored.buffer.active.cursorY).toBe(2)
|
||||
expect(visibleText(restored)[0]).toBe('0123456789')
|
||||
emulator.dispose()
|
||||
})
|
||||
|
||||
it('serializeWithAbsoluteCursor restores the exact cursor', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
await write(terminal, REPRO)
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
expect(restored.buffer.active.cursorX).toBe(4)
|
||||
expect(restored.buffer.active.cursorY).toBe(2)
|
||||
})
|
||||
|
||||
it('never changes already-correct restores at various cursor positions', async () => {
|
||||
const positions = ['\x1b[1;1H', '\x1b[2;4H', '\x1b[5;10H', '\x1b[4;1H']
|
||||
for (const cup of positions) {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
await write(terminal, `hello\r\nworld${cup}`)
|
||||
const plainRestore = await replay(addon.serialize())
|
||||
const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
expect(hardenedRestore.buffer.active.cursorX).toBe(terminal.buffer.active.cursorX)
|
||||
expect(hardenedRestore.buffer.active.cursorY).toBe(terminal.buffer.active.cursorY)
|
||||
expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX)
|
||||
expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY)
|
||||
expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore))
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a wrap-pending source untouched so replay stays wrap-pending', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
await write(terminal, '0123456789')
|
||||
// cursorX == cols marks pending wrap; a CUP would clamp and clear it.
|
||||
expect(terminal.buffer.active.cursorX).toBe(10)
|
||||
const plain = addon.serialize()
|
||||
expect(serializeWithAbsoluteCursor(addon, terminal)).toBe(plain)
|
||||
const restored = await replay(plain)
|
||||
await write(restored, 'Z')
|
||||
expect(visibleText(restored)[1]).toBe('Z')
|
||||
})
|
||||
|
||||
it('handles a wrap-pending row that is NOT the final content row', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
// Row 0 fills to the margin and wraps into row 1, then the cursor moves.
|
||||
await write(terminal, '0123456789ABC\x1b[2;2H')
|
||||
expect(terminal.buffer.active.cursorY).toBe(1)
|
||||
expect(terminal.buffer.active.cursorX).toBe(1)
|
||||
const plainRestore = await replay(addon.serialize())
|
||||
const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
expect(hardenedRestore.buffer.active.cursorX).toBe(1)
|
||||
expect(hardenedRestore.buffer.active.cursorY).toBe(1)
|
||||
expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore))
|
||||
expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX)
|
||||
expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY)
|
||||
})
|
||||
|
||||
it('restores alt-screen snapshots without disturbing correct positioning', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
await write(terminal, 'shell$\x1b[?1049h\x1b[2J\x1b[HTUI ROW\x1b[2;3H')
|
||||
expect(terminal.buffer.active.type).toBe('alternate')
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
expect(restored.buffer.active.type).toBe('alternate')
|
||||
expect(restored.buffer.active.cursorX).toBe(2)
|
||||
expect(restored.buffer.active.cursorY).toBe(1)
|
||||
expect(visibleText(restored)[0]).toBe('TUI ROW')
|
||||
})
|
||||
|
||||
it('fixes the wrap-pending off-by-one inside the alt screen too', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
await write(terminal, '\x1b[?1049h0123456789\x1b[3;5H')
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
expect(restored.buffer.active.type).toBe('alternate')
|
||||
expect(restored.buffer.active.cursorX).toBe(4)
|
||||
expect(restored.buffer.active.cursorY).toBe(2)
|
||||
})
|
||||
|
||||
it('restores scrolled-back buffers with the cursor at its base-relative spot', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 3, 50)
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await write(terminal, `line${i}\r\n`)
|
||||
}
|
||||
await write(terminal, '\x1b[2;3H')
|
||||
const source = { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY }
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal), 10, 3, 50)
|
||||
expect(restored.buffer.active.cursorX).toBe(source.x)
|
||||
expect(restored.buffer.active.cursorY).toBe(source.y)
|
||||
expect(visibleText(restored)).toEqual(visibleText(terminal))
|
||||
expect(restored.buffer.active.length).toBe(terminal.buffer.active.length)
|
||||
})
|
||||
|
||||
it('keeps empty buffers serializing to an empty string', async () => {
|
||||
const { terminal, addon } = createTerminal(10, 5)
|
||||
expect(addon.serialize()).toBe('')
|
||||
expect(serializeWithAbsoluteCursor(addon, terminal)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
|
||||
* bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime
|
||||
* headless emulator. The headless xterm core has no theme service, so these
|
||||
* handlers compute replies from the renderer's pushed attribute snapshot,
|
||||
* with per-PTY OSC SET mutations layered on top — mirroring exactly what the
|
||||
* renderer's ThemeService reports for a visible pane. Replies route through
|
||||
* the caller's emit sink, which the slice-1 forwarding window already gates,
|
||||
* so seeded/replayed bytes and delivered chunks never produce a reply.
|
||||
*/
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import {
|
||||
formatXColorRgbSpec,
|
||||
parseXColorSpec,
|
||||
TERMINAL_VIEW_ANSI_COLOR_COUNT,
|
||||
type TerminalViewAttributes,
|
||||
type TerminalViewRgb
|
||||
} from '../../shared/terminal-view-attributes'
|
||||
|
||||
type ViewAttributeParser = Pick<Terminal['parser'], 'registerOscHandler' | 'registerCsiHandler'>
|
||||
|
||||
export type TerminalViewAttributeResponderDeps = {
|
||||
parser: ViewAttributeParser
|
||||
/** Last renderer push, or null before the first push. Null means SILENCE
|
||||
* for every view-attribute query — a fabricated default would resurrect
|
||||
* the default-black OSC-11 bug (design invariant 3). */
|
||||
getBaseAttributes: () => TerminalViewAttributes | null
|
||||
/** Must already be replay/forwarding-window gated by the caller. */
|
||||
emitReply: (reply: string) => void
|
||||
}
|
||||
|
||||
export type TerminalViewAttributeResponder = {
|
||||
/** A changed renderer attribute push replaces the whole palette, exactly
|
||||
* like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors
|
||||
* on a visible pane's theme apply. Identical re-pushes (fresh renderer
|
||||
* process) are filtered in main's store and never reach this. */
|
||||
clearColorOverrides: () => void
|
||||
}
|
||||
|
||||
type SpecialColorSlot = 'foreground' | 'background' | 'cursor'
|
||||
|
||||
// OSC 10/11/12 stack extra params onto consecutive slots (xterm's
|
||||
// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background.
|
||||
const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor']
|
||||
const SPECIAL_COLOR_IDENTS: Record<SpecialColorSlot, string> = {
|
||||
foreground: '10',
|
||||
background: '11',
|
||||
cursor: '12'
|
||||
}
|
||||
|
||||
function isValidColorIndex(value: number): boolean {
|
||||
return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT
|
||||
}
|
||||
|
||||
// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) —
|
||||
// the math CoreBrowserTerminal._reportColorScheme answers ?996n with.
|
||||
function relativeLuminance([r, g, b]: TerminalViewRgb): number {
|
||||
const linear = (channel: number): number => {
|
||||
const c = channel / 255
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722
|
||||
}
|
||||
|
||||
export function installTerminalViewAttributeResponder(
|
||||
deps: TerminalViewAttributeResponderDeps
|
||||
): TerminalViewAttributeResponder {
|
||||
// Why per-instance maps: SET mutations are per PTY (one emulator per PTY);
|
||||
// they die with the emulator at teardown, like every other model state.
|
||||
// They deliberately survive a reveal→re-hide cycle even though the revealed
|
||||
// xterm restores without palette mutations (SerializeAddon emits no OSC
|
||||
// color SETs): the TUI never reset its SET, so holding it is
|
||||
// protocol-correct — the visible-side loss is the pre-existing restore
|
||||
// limitation, not this model's.
|
||||
const ansiOverrides = new Map<number, TerminalViewRgb>()
|
||||
const specialOverrides = new Map<SpecialColorSlot, TerminalViewRgb>()
|
||||
|
||||
const reportColor = (ident: string, rgb: TerminalViewRgb): void => {
|
||||
// Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the
|
||||
// renderer xterm's reply (CoreBrowserTerminal._handleColorEvent).
|
||||
deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`)
|
||||
}
|
||||
|
||||
const handleSpecialColor = (data: string, offset: number): boolean => {
|
||||
const slots = data.split(';')
|
||||
for (let i = 0; i < slots.length; ++i, ++offset) {
|
||||
if (offset >= SPECIAL_COLOR_SLOTS.length) {
|
||||
break
|
||||
}
|
||||
const slot = SPECIAL_COLOR_SLOTS[offset]
|
||||
if (slots[i] === '?') {
|
||||
const base = deps.getBaseAttributes()
|
||||
if (base) {
|
||||
reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot])
|
||||
}
|
||||
} else {
|
||||
const rgb = parseXColorSpec(slots[i])
|
||||
if (rgb) {
|
||||
specialOverrides.set(slot, rgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
// True consumes the sequence; the headless core's own OSC 10/11/12
|
||||
// handler only fires an onColor event nothing consumes.
|
||||
return true
|
||||
}
|
||||
|
||||
deps.parser.registerOscHandler(4, (data) => {
|
||||
const slots = data.split(';')
|
||||
while (slots.length > 1) {
|
||||
const idx = slots.shift() as string
|
||||
const spec = slots.shift() as string
|
||||
if (!/^\d+$/.test(idx)) {
|
||||
continue
|
||||
}
|
||||
const index = Number.parseInt(idx, 10)
|
||||
if (!isValidColorIndex(index)) {
|
||||
continue
|
||||
}
|
||||
if (spec === '?') {
|
||||
const base = deps.getBaseAttributes()
|
||||
if (base) {
|
||||
reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index])
|
||||
}
|
||||
} else {
|
||||
const rgb = parseXColorSpec(spec)
|
||||
if (rgb) {
|
||||
ansiOverrides.set(index, rgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0))
|
||||
deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1))
|
||||
deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2))
|
||||
|
||||
// OSC 104/110/111/112 restore the themed color — dropping the override
|
||||
// falls back to the pushed base, the model twin of ThemeService.restoreColor.
|
||||
deps.parser.registerOscHandler(104, (data) => {
|
||||
if (!data) {
|
||||
ansiOverrides.clear()
|
||||
return true
|
||||
}
|
||||
for (const slot of data.split(';')) {
|
||||
if (/^\d+$/.test(slot)) {
|
||||
ansiOverrides.delete(Number.parseInt(slot, 10))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(110, () => {
|
||||
specialOverrides.delete('foreground')
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(111, () => {
|
||||
specialOverrides.delete('background')
|
||||
return true
|
||||
})
|
||||
deps.parser.registerOscHandler(112, () => {
|
||||
specialOverrides.delete('cursor')
|
||||
return true
|
||||
})
|
||||
|
||||
deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => {
|
||||
if (params[0] !== 996) {
|
||||
// Fall through to the core for every other private DSR (?6n CPR etc.).
|
||||
return false
|
||||
}
|
||||
const base = deps.getBaseAttributes()
|
||||
if (base) {
|
||||
// Why luminance and not base.colorSchemeMode: a visible xterm answers
|
||||
// ?996n from the relative luminance of the CURRENT (OSC-SET-mutated)
|
||||
// background vs foreground (CoreBrowserTerminal._reportColorScheme),
|
||||
// so a dark terminal theme in a light app mode still answers dark.
|
||||
// colorSchemeMode is the app mode and feeds the 2031/997 path only.
|
||||
const background = specialOverrides.get('background') ?? base.background
|
||||
const foreground = specialOverrides.get('foreground') ?? base.foreground
|
||||
const dark = relativeLuminance(background) < relativeLuminance(foreground)
|
||||
deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
clearColorOverrides: () => {
|
||||
ansiOverrides.clear()
|
||||
specialOverrides.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
-59
@@ -3,11 +3,13 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges
|
||||
// ─── Protocol Version ────────────────────────────────────────────────
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
|
||||
// Why: daemons survive app updates; bump for IPC shape or baked behavior that
|
||||
// wrapper refresh cannot deliver, so old daemons reject unsupported RPCs.
|
||||
export const PROTOCOL_VERSION = 19
|
||||
// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or
|
||||
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
|
||||
// Why: bump when adding daemon wire behavior so same-version old daemons do
|
||||
// not silently accept the handshake and then reject new RPCs.
|
||||
export const PROTOCOL_VERSION = 20
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
|
||||
] as const
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
@@ -18,23 +20,27 @@ export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported'
|
||||
// ─── Terminal Snapshot ──────────────────────────────────────────────
|
||||
export type TerminalSnapshot = {
|
||||
snapshotAnsi: string
|
||||
/** Scrollback portion only (rows above the visible viewport). Write this
|
||||
* to preserve history without interfering with TUI repaints. */
|
||||
/** Trailing incomplete escape sequence the emulator ingested but xterm's
|
||||
* parser is still holding (a PTY read ended mid-escape). Restorers must
|
||||
* write this LAST — after their own post-replay resets, immediately before
|
||||
* post-snapshot live chunks — so the continuation bytes complete it
|
||||
* exactly as live (Bug E / #7329, notes/garble-fuzz-divergences.md). Its
|
||||
* bytes are already counted by the snapshot seq. */
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Normal buffer captured separately while snapshotAnsi holds an active
|
||||
* alternate buffer. Empty for normal-screen snapshots. */
|
||||
scrollbackAnsi: string
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
rehydrateSequences: string
|
||||
/** The trailing partial escape sequence left unparsed in the emulator when a
|
||||
* PTY read ended mid-escape. serialize() cannot carry it (it lives in the
|
||||
* parser, not the buffer), so the restorer must write it LAST — after any
|
||||
* post-snapshot reset — so the next live chunk's continuation completes the
|
||||
* sequence instead of rendering literally (#7329). */
|
||||
pendingEscapeTailAnsi?: string
|
||||
cwd: string | null
|
||||
modes: TerminalModes
|
||||
cols: number
|
||||
rows: number
|
||||
scrollbackLines: number
|
||||
lastTitle?: string
|
||||
/** Absolute UTF-16 character count ingested by this live daemon session.
|
||||
* Optional because persisted snapshots and older v19 daemons lack it. */
|
||||
outputSequence?: number
|
||||
}
|
||||
|
||||
export type TerminalModes = {
|
||||
@@ -45,9 +51,14 @@ export type TerminalModes = {
|
||||
sgrMousePixelsMode?: boolean
|
||||
applicationCursor: boolean
|
||||
alternateScreen: boolean
|
||||
/** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated;
|
||||
* 0/absent when inactive. SerializeAddon cannot capture these, so the
|
||||
* emulator mirrors them for snapshot rehydration. */
|
||||
/** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed
|
||||
* parity ONLY. Consumed by the daemon warm-reattach path: the spawn
|
||||
* result threads them into seedHeadlessTerminal, which re-applies them to
|
||||
* the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags)
|
||||
* so hidden `CSI ? u` answers the real flags instead of ?0u.
|
||||
* rehydrateSequences must never push these into a renderer xterm —
|
||||
* POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative
|
||||
* (terminal-query-authority.md §kitty). */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
@@ -134,6 +145,38 @@ export type ResizeRequest = {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Producer flow control (v19+) ───────────────────────────────────
|
||||
// Why fire-and-forget notifications (like write/resize): pause/resume ride the
|
||||
// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC
|
||||
// reply, is what guarantees a paused shell can never stay wedged.
|
||||
export type PausePtyRequest = {
|
||||
id: string
|
||||
type: 'pausePty'
|
||||
payload: {
|
||||
sessionId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ResumePtyRequest = {
|
||||
id: string
|
||||
type: 'resumePty'
|
||||
payload: {
|
||||
sessionId: string
|
||||
}
|
||||
}
|
||||
|
||||
// Why the notification stays backward-tolerated: unknown notify types are
|
||||
// swallowed by old daemons. The adapter's v20 capability gate separately
|
||||
// prevents v19 thinning without a sequence-safe recovery snapshot.
|
||||
export type SetSessionBackgroundRequest = {
|
||||
id: string
|
||||
type: 'setSessionBackground'
|
||||
payload: {
|
||||
sessionId: string
|
||||
background: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type KillRequest = {
|
||||
id: string
|
||||
type: 'kill'
|
||||
@@ -217,6 +260,7 @@ export type GetSnapshotRequest = {
|
||||
type: 'getSnapshot'
|
||||
payload: {
|
||||
sessionId: string
|
||||
scrollbackRows?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +321,9 @@ export type DaemonRequest =
|
||||
| CancelCreateOrAttachRequest
|
||||
| WriteRequest
|
||||
| ResizeRequest
|
||||
| PausePtyRequest
|
||||
| ResumePtyRequest
|
||||
| SetSessionBackgroundRequest
|
||||
| KillRequest
|
||||
| SignalRequest
|
||||
| ListSessionsRequest
|
||||
@@ -351,30 +398,9 @@ export type DaemonSessionInfo = SessionInfo & {
|
||||
protocolVersion: number
|
||||
}
|
||||
|
||||
// ─── Events (Daemon → Client, on stream socket) ────────────────────
|
||||
|
||||
export type DataEvent = {
|
||||
type: 'event'
|
||||
event: 'data'
|
||||
sessionId: string
|
||||
payload: { data: string }
|
||||
}
|
||||
|
||||
export type ExitEvent = {
|
||||
type: 'event'
|
||||
event: 'exit'
|
||||
sessionId: string
|
||||
payload: { code: number }
|
||||
}
|
||||
|
||||
export type TerminalErrorEvent = {
|
||||
type: 'event'
|
||||
event: 'terminalError'
|
||||
sessionId: string
|
||||
payload: { message: string }
|
||||
}
|
||||
|
||||
export type DaemonEvent = DataEvent | ExitEvent | TerminalErrorEvent
|
||||
// Stream-socket event shapes live in daemon-stream-events.ts; re-exported so
|
||||
// existing importers keep one types entry point.
|
||||
export * from './daemon-stream-events'
|
||||
|
||||
// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ────────────────
|
||||
//
|
||||
@@ -399,23 +425,10 @@ export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB
|
||||
export const NOTIFY_PREFIX = 'notify_'
|
||||
|
||||
// ─── Error types ────────────────────────────────────────────────────
|
||||
export class TerminalAttachCanceledError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Attach canceled for session ${sessionId}`)
|
||||
this.name = 'TerminalAttachCanceledError'
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonProtocolError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'DaemonProtocolError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Session not found: ${sessionId}`)
|
||||
this.name = 'SessionNotFoundError'
|
||||
}
|
||||
}
|
||||
// Re-exported so existing importers of `./types` keep working; the classes
|
||||
// live in daemon-errors.ts (this file is capped for wire-shape declarations).
|
||||
export {
|
||||
TerminalAttachCanceledError,
|
||||
DaemonProtocolError,
|
||||
SessionNotFoundError
|
||||
} from './daemon-errors'
|
||||
|
||||
+27
-1
@@ -179,6 +179,7 @@ import {
|
||||
type SyntheticTitleSpinnerEntry
|
||||
} from './synthetic-title-spinner'
|
||||
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
|
||||
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
|
||||
import { isCrashReportReason } from '../shared/crash-reporting'
|
||||
import {
|
||||
getSyntheticAgentTitleProfile,
|
||||
@@ -187,6 +188,7 @@ import {
|
||||
} from '../shared/synthetic-agent-title'
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import { KeybindingService } from './keybindings/keybinding-service'
|
||||
import { applyElectronProxySettings } from './network/proxy-settings'
|
||||
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
||||
@@ -1504,7 +1506,17 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool
|
||||
) {
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.send('pty:data', { id: ptyId, data })
|
||||
// Why: feed the per-PTY tracker directly (never onPtyData — emulator state,
|
||||
// tails, transcripts, and stats must not see fabricated bytes) so synthetic
|
||||
// titles/BELs reach pty:sideEffect consumers when main holds side-effect
|
||||
// authority.
|
||||
runtime?.ingestSyntheticTitleFrame(ptyId, data)
|
||||
// Why: only the kill-switch-off renderer still byte-parses synthetic frames;
|
||||
// under main authority the copy would just mint phantom ACKs for unmetered
|
||||
// bytes (see synthetic-title-frame-routing.ts).
|
||||
if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) {
|
||||
mainWindow.webContents.send('pty:data', { id: ptyId, data })
|
||||
}
|
||||
}
|
||||
|
||||
function isSyntheticTitleWindowVisible(): boolean {
|
||||
@@ -1805,6 +1817,20 @@ app.whenReady().then(async () => {
|
||||
onTerminalAgentStatus: (event) => {
|
||||
agentHookServer.ingestTerminalStatus(event)
|
||||
},
|
||||
// Why: derived title/bell/agent facts ride one batched main→renderer
|
||||
// channel (terminal-side-effect-authority.md). The renderer's authority
|
||||
// kill switch decides whether to consume. Headless serve never creates a
|
||||
// window, so the dep is omitted entirely — the runtime then skips fact
|
||||
// batch construction and the per-chunk bell walk.
|
||||
...(isServeMode
|
||||
? {}
|
||||
: {
|
||||
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:sideEffect', batch)
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Why: hook-reported agent status is the same source the desktop sidebar
|
||||
// reads. worktree.ps pulls it at query time so mobile shows the same agents.
|
||||
getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(),
|
||||
|
||||
@@ -216,7 +216,19 @@ export async function listFilesWithGit(
|
||||
const onAbort = (): void => killSurvivors('git ls-files cancelled')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
await Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)])
|
||||
await Promise.all([
|
||||
runGitLsFiles(primary),
|
||||
// Why: ignored files are supplementary — a failed or timed-out ignored
|
||||
// pass must not discard the primary listing the user actually needs
|
||||
// (#7719 root cause: the all-or-nothing failure showed zero files).
|
||||
// Entries streamed before the failure are kept; a cancelled scan still
|
||||
// rejects via the primary pass or the expansion's cancellation check.
|
||||
runGitLsFiles(ignoredPass).catch((err: Error) => {
|
||||
if (!signal?.aborted) {
|
||||
console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err)
|
||||
}
|
||||
})
|
||||
])
|
||||
} catch (err) {
|
||||
killSurvivors()
|
||||
if (signal?.aborted) {
|
||||
|
||||
@@ -475,6 +475,60 @@ describe('filesystem-list-files', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps primary results when only the ignored pass times out', async () => {
|
||||
checkRgAvailableMock.mockResolvedValue(false)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const revParseProc = createMockProcess()
|
||||
const gitP1 = createMockProcess()
|
||||
const gitP2 = createMockProcess()
|
||||
let callIndex = 0
|
||||
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === 'git' && args.includes('rev-parse')) {
|
||||
return revParseProc
|
||||
}
|
||||
if (cmd === 'git' && args.includes('ls-files')) {
|
||||
callIndex++
|
||||
return callIndex === 1 ? gitP1 : gitP2
|
||||
}
|
||||
return createMockProcess()
|
||||
})
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
revParseProc.emit('close', 0, null)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
`${staged('100644', 'src/index.ts')}\0`
|
||||
)
|
||||
gitP1.emit('close', 0, null)
|
||||
// Ignored entries streamed before the timeout are kept.
|
||||
;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
|
||||
await expect(promise).resolves.toEqual(
|
||||
expect.arrayContaining(['src/index.ts', 'dist/generated.js'])
|
||||
)
|
||||
expect(gitP2.kill).toHaveBeenCalled()
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back to git when rg is available', async () => {
|
||||
checkRgAvailableMock.mockResolvedValue(true)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
clearHiddenRendererPtyDeliveryState,
|
||||
getHiddenRendererPtyDeliveryDebug,
|
||||
isHiddenPtyDeliveryGateEnabled,
|
||||
markHiddenRendererPty,
|
||||
recordHiddenRendererPtyDataDrop,
|
||||
resetRendererScopedHiddenPtyDeliveryState,
|
||||
setRendererPtyDeliveryInterest,
|
||||
shouldDropHiddenRendererPtyData,
|
||||
unmarkHiddenRendererPty
|
||||
} from './pty-hidden-delivery-gate'
|
||||
|
||||
const PTY_ID = 'pty-1'
|
||||
|
||||
describe('pty hidden delivery gate', () => {
|
||||
beforeEach(() => {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
})
|
||||
|
||||
it('only operates when both kill switches are on (default on)', () => {
|
||||
expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true)
|
||||
expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true)
|
||||
expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false)
|
||||
expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('drops only hidden PTYs without registered delivery interest', () => {
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
setRendererPtyDeliveryInterest(PTY_ID, true)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
setRendererPtyDeliveryInterest(PTY_ID, false)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true)
|
||||
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false)
|
||||
|
||||
// Why: unmark consumes the latch (and re-emits via its own return value);
|
||||
// the next hidden period's first drop reports again.
|
||||
unmarkHiddenRendererPty(PTY_ID)
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => {
|
||||
// Why: a hidden remount or renderer reload re-marks without an unhide in
|
||||
// between — clearing the latch there would make reveal skip the restore.
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
|
||||
})
|
||||
|
||||
it('reports drops on unhide so reveal can heal a replaced renderer view', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false)
|
||||
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('clears renderer-scoped state on reload while preserving drop memory', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
|
||||
setRendererPtyDeliveryInterest('pty-2', true)
|
||||
markHiddenRendererPty('pty-2')
|
||||
|
||||
resetRendererScopedHiddenPtyDeliveryState()
|
||||
|
||||
// Hidden marks and interest holds died with the old renderer process.
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({
|
||||
hiddenDeliveryGatedPtyCount: 0,
|
||||
deliveryInterestPtyCount: 0
|
||||
})
|
||||
// pty-2's leaked interest is gone: re-marking gates it again.
|
||||
markHiddenRendererPty('pty-2')
|
||||
expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true)
|
||||
// Drop memory survives so the new renderer's first unhide still restores.
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
|
||||
})
|
||||
|
||||
it('clears all per-PTY state on teardown and tracks debug counters', () => {
|
||||
markHiddenRendererPty(PTY_ID)
|
||||
setRendererPtyDeliveryInterest('pty-2', true)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 7)
|
||||
recordHiddenRendererPtyDataDrop(PTY_ID, 5)
|
||||
|
||||
expect(getHiddenRendererPtyDeliveryDebug()).toEqual({
|
||||
hiddenDeliveryGatedPtyCount: 1,
|
||||
deliveryInterestPtyCount: 1,
|
||||
hiddenDeliveryDroppedChars: 12,
|
||||
hiddenDeliveryDroppedChunks: 2
|
||||
})
|
||||
|
||||
clearHiddenRendererPtyDeliveryState(PTY_ID)
|
||||
clearHiddenRendererPtyDeliveryState('pty-2')
|
||||
expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({
|
||||
hiddenDeliveryGatedPtyCount: 0,
|
||||
deliveryInterestPtyCount: 0
|
||||
})
|
||||
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of
|
||||
* the terminal model/view architecture).
|
||||
*
|
||||
* The renderer marks a PTY hidden when no visible view consumes its bytes;
|
||||
* main then drops renderer-bound delivery AFTER model ingestion — the runtime
|
||||
* already parsed the chunk, and reveal restores from the model snapshot via
|
||||
* the existing seq-guarded machinery. Any renderer party that still needs raw
|
||||
* bytes (dispatcher sidecars) registers delivery
|
||||
* interest, which suppresses the gate for that PTY.
|
||||
* See docs/reference/terminal-side-effect-authority.md (Open Items).
|
||||
*/
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
export type HiddenPtyDeliveryGateSettings = Pick<
|
||||
GlobalSettings,
|
||||
'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate'
|
||||
>
|
||||
|
||||
const hiddenRendererPtys = new Set<string>()
|
||||
// Why: sidecar consumers (paste-draft pacing, background agent launches,
|
||||
// automation observers, and the kill-switch-off parked 2031 responder) need
|
||||
// live bytes even while no visible view exists. Any
|
||||
// registered interest suppresses the gate for that PTY.
|
||||
const deliveryInterestRendererPtys = new Set<string>()
|
||||
// Why: reveal must restore from the model only when bytes were actually
|
||||
// dropped. Doubles as the one-shot marker latch: the first gated drop emits a
|
||||
// restore marker, and the latch is consumed only by unmark (which re-emits)
|
||||
// or full PTY teardown — never by re-marking hidden, so drop memory survives
|
||||
// hidden remounts and renderer reloads.
|
||||
const droppedSinceHiddenPtys = new Set<string>()
|
||||
|
||||
let droppedHiddenDeliveryChars = 0
|
||||
let droppedHiddenDeliveryChunks = 0
|
||||
|
||||
/** Gate kill switches, both read main-side: the gate only operates under main
|
||||
* side-effect authority AND the gate-specific setting (both default on). */
|
||||
export function isHiddenPtyDeliveryGateEnabled(
|
||||
settings: HiddenPtyDeliveryGateSettings | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
settings?.terminalMainSideEffectAuthority !== false &&
|
||||
settings?.terminalHiddenDeliveryGate !== false
|
||||
)
|
||||
}
|
||||
|
||||
/** Renderer-reported "no visible view needs bytes" bit. Never clears drop
|
||||
* memory: a hidden remount or renderer reload re-marks an already-dropped
|
||||
* PTY, and erasing the latch there would make the eventual reveal skip the
|
||||
* restore. Unmark is the only consumer of the latch. */
|
||||
export function markHiddenRendererPty(id: string): void {
|
||||
hiddenRendererPtys.add(id)
|
||||
}
|
||||
|
||||
/** Clears the hidden bit. Returns whether bytes were dropped while hidden so
|
||||
* the caller can emit a restore marker to the now-visible renderer. */
|
||||
export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } {
|
||||
hiddenRendererPtys.delete(id)
|
||||
const droppedWhileHidden = droppedSinceHiddenPtys.delete(id)
|
||||
return { droppedWhileHidden }
|
||||
}
|
||||
|
||||
export function isHiddenRendererPty(id: string): boolean {
|
||||
return hiddenRendererPtys.has(id)
|
||||
}
|
||||
|
||||
/** For freeze diagnostics only: hidden ptys must appear in the per-pty report
|
||||
* table even when the gate dropped every byte before any send/accounting. */
|
||||
export function getHiddenRendererPtyIds(): string[] {
|
||||
return [...hiddenRendererPtys]
|
||||
}
|
||||
|
||||
/** Renderer-side ref-counted interest, surfaced as boolean transitions. */
|
||||
export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void {
|
||||
if (interested) {
|
||||
deliveryInterestRendererPtys.add(id)
|
||||
} else {
|
||||
deliveryInterestRendererPtys.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldDropHiddenRendererPtyData(
|
||||
id: string,
|
||||
settings: HiddenPtyDeliveryGateSettings | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
isHiddenPtyDeliveryGateEnabled(settings) &&
|
||||
hiddenRendererPtys.has(id) &&
|
||||
!deliveryInterestRendererPtys.has(id)
|
||||
)
|
||||
}
|
||||
|
||||
/** Record one gated drop. Returns whether the caller should emit the one-shot
|
||||
* empty restore-marker chunk (first drop since this PTY went hidden). */
|
||||
export function recordHiddenRendererPtyDataDrop(
|
||||
id: string,
|
||||
chars: number
|
||||
): { shouldEmitRestoreMarker: boolean } {
|
||||
droppedHiddenDeliveryChars += chars
|
||||
droppedHiddenDeliveryChunks += 1
|
||||
if (droppedSinceHiddenPtys.has(id)) {
|
||||
return { shouldEmitRestoreMarker: false }
|
||||
}
|
||||
droppedSinceHiddenPtys.add(id)
|
||||
return { shouldEmitRestoreMarker: true }
|
||||
}
|
||||
|
||||
/** Renderer process replaced (reload / crash): its ref-counted interest
|
||||
* holds and hidden marks died with it, so keeping them would gate (or
|
||||
* force-feed) PTYs no live renderer party asked about. Drop memory is
|
||||
* preserved — surviving daemon/SSH PTYs may have dropped bytes the old
|
||||
* renderer never restored; the new renderer's first hidden/visible sync
|
||||
* re-marks or unmarks and the unmark path re-emits the restore marker. */
|
||||
export function resetRendererScopedHiddenPtyDeliveryState(): void {
|
||||
hiddenRendererPtys.clear()
|
||||
deliveryInterestRendererPtys.clear()
|
||||
}
|
||||
|
||||
/** Full per-PTY teardown — wired into clearProviderPtyState so every exit
|
||||
* path (local, daemon, SSH, connection teardown) releases gate state. */
|
||||
export function clearHiddenRendererPtyDeliveryState(id: string): void {
|
||||
hiddenRendererPtys.delete(id)
|
||||
deliveryInterestRendererPtys.delete(id)
|
||||
droppedSinceHiddenPtys.delete(id)
|
||||
}
|
||||
|
||||
export type HiddenRendererPtyDeliveryDebug = {
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
}
|
||||
|
||||
export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug {
|
||||
return {
|
||||
hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size,
|
||||
deliveryInterestPtyCount: deliveryInterestRendererPtys.size,
|
||||
hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars,
|
||||
hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks
|
||||
}
|
||||
}
|
||||
|
||||
export function resetHiddenRendererPtyDeliveryDebugCounters(): void {
|
||||
droppedHiddenDeliveryChars = 0
|
||||
droppedHiddenDeliveryChunks = 0
|
||||
}
|
||||
|
||||
/** Test seam: reset all module state between tests. */
|
||||
export function _resetHiddenRendererPtyDeliveryGateForTest(): void {
|
||||
hiddenRendererPtys.clear()
|
||||
deliveryInterestRendererPtys.clear()
|
||||
droppedSinceHiddenPtys.clear()
|
||||
resetHiddenRendererPtyDeliveryDebugCounters()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PRODUCER_FLOW_HIGH_WATERMARK_CHARS,
|
||||
PRODUCER_FLOW_LOW_WATERMARK_CHARS,
|
||||
PRODUCER_PAUSE_REASSERT_INTERVAL_MS,
|
||||
PtyProducerFlowController
|
||||
} from './pty-producer-flow-control'
|
||||
|
||||
const HIGH = PRODUCER_FLOW_HIGH_WATERMARK_CHARS
|
||||
const LOW = PRODUCER_FLOW_LOW_WATERMARK_CHARS
|
||||
|
||||
describe('PtyProducerFlowController', () => {
|
||||
let pauseProducer: ReturnType<typeof vi.fn<(id: string) => void>>
|
||||
let resumeProducer: ReturnType<typeof vi.fn<(id: string) => void>>
|
||||
let controller: PtyProducerFlowController
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
pauseProducer = vi.fn<(id: string) => void>()
|
||||
resumeProducer = vi.fn<(id: string) => void>()
|
||||
controller = new PtyProducerFlowController({
|
||||
pauseProducer,
|
||||
resumeProducer
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not pause at or below the high watermark', () => {
|
||||
controller.update('pty-1', 0)
|
||||
controller.update('pty-1', LOW)
|
||||
controller.update('pty-1', HIGH)
|
||||
expect(pauseProducer).not.toHaveBeenCalled()
|
||||
expect(controller.isPaused('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses exactly once when pending crosses the high watermark, not per chunk', () => {
|
||||
controller.update('pty-1', HIGH + 1)
|
||||
controller.update('pty-1', HIGH + 64 * 1024)
|
||||
controller.update('pty-1', HIGH + 128 * 1024)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(1)
|
||||
expect(pauseProducer).toHaveBeenCalledWith('pty-1')
|
||||
expect(controller.isPaused('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('resumes exactly once when pending drains below the low watermark', () => {
|
||||
controller.update('pty-1', HIGH + 1)
|
||||
controller.update('pty-1', LOW - 1)
|
||||
expect(resumeProducer).toHaveBeenCalledTimes(1)
|
||||
expect(resumeProducer).toHaveBeenCalledWith('pty-1')
|
||||
expect(controller.isPaused('pty-1')).toBe(false)
|
||||
// A second drain report on the now-unpaused pty must not resume again.
|
||||
controller.update('pty-1', 0)
|
||||
expect(resumeProducer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('holds hysteresis: no flapping while pending sits between the watermarks', () => {
|
||||
controller.update('pty-1', HIGH + 1)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(1)
|
||||
// Draining but still above LOW: stay paused, no extra calls either way.
|
||||
controller.update('pty-1', HIGH - 16 * 1024)
|
||||
controller.update('pty-1', 128 * 1024)
|
||||
controller.update('pty-1', LOW)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(1)
|
||||
expect(resumeProducer).not.toHaveBeenCalled()
|
||||
expect(controller.isPaused('pty-1')).toBe(true)
|
||||
// An unpaused pty hovering in the same band must not pause.
|
||||
controller.update('pty-2', LOW + 1)
|
||||
controller.update('pty-2', HIGH)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-asserts the pause after the failsafe interval while still flooded', () => {
|
||||
controller.update('pty-1', HIGH + 1)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Within the failsafe window: no re-assert even far above HIGH.
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_REASSERT_INTERVAL_MS - 1)
|
||||
controller.update('pty-1', HIGH * 4)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(1)
|
||||
|
||||
// After the window (daemon failsafe has auto-resumed by now): re-pause.
|
||||
vi.advanceTimersByTime(1)
|
||||
controller.update('pty-1', HIGH * 4)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The re-assert re-stamps the clock — no immediate third pause.
|
||||
controller.update('pty-1', HIGH * 4)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('release resumes only ptys that are actually paused', () => {
|
||||
controller.update('paused-pty', HIGH + 1)
|
||||
controller.release('paused-pty')
|
||||
controller.release('never-paused-pty')
|
||||
expect(resumeProducer).toHaveBeenCalledTimes(1)
|
||||
expect(resumeProducer).toHaveBeenCalledWith('paused-pty')
|
||||
expect(controller.isPaused('paused-pty')).toBe(false)
|
||||
})
|
||||
|
||||
it('releaseAll resumes every paused pty', () => {
|
||||
controller.update('pty-1', HIGH + 1)
|
||||
controller.update('pty-2', HIGH + 1)
|
||||
controller.update('pty-3', LOW)
|
||||
controller.releaseAll()
|
||||
expect(resumeProducer).toHaveBeenCalledTimes(2)
|
||||
expect(resumeProducer).toHaveBeenCalledWith('pty-1')
|
||||
expect(resumeProducer).toHaveBeenCalledWith('pty-2')
|
||||
expect(controller.isPaused('pty-1')).toBe(false)
|
||||
expect(controller.isPaused('pty-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps bookkeeping consistent when the transport throws', () => {
|
||||
pauseProducer.mockImplementation(() => {
|
||||
throw new Error('provider gone')
|
||||
})
|
||||
resumeProducer.mockImplementation(() => {
|
||||
throw new Error('provider gone')
|
||||
})
|
||||
expect(() => controller.update('pty-1', HIGH + 1)).not.toThrow()
|
||||
expect(controller.isPaused('pty-1')).toBe(true)
|
||||
expect(() => controller.update('pty-1', 0)).not.toThrow()
|
||||
expect(controller.isPaused('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks watermark state per pty independently', () => {
|
||||
controller.update('pty-1', HIGH + 1)
|
||||
controller.update('pty-2', HIGH + 1)
|
||||
controller.update('pty-1', 0)
|
||||
expect(pauseProducer).toHaveBeenCalledTimes(2)
|
||||
expect(resumeProducer).toHaveBeenCalledTimes(1)
|
||||
expect(controller.isPaused('pty-1')).toBe(false)
|
||||
expect(controller.isPaused('pty-2')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// Producer-side PTY flow control (notes/terminal-performance-initiative.md §5).
|
||||
// Main tracks per-PTY renderer-pending chars; past HIGH it asks the provider to
|
||||
// pause the actual PTY read (node-pty pause() → kernel backpressure → the
|
||||
// flooding shell blocks on write), and below LOW it resumes. The wide
|
||||
// HIGH/LOW gap is deliberate hysteresis so a draining queue cannot flap
|
||||
// pause/resume once per flush slice.
|
||||
|
||||
export const PRODUCER_FLOW_HIGH_WATERMARK_CHARS = 256 * 1024
|
||||
export const PRODUCER_FLOW_LOW_WATERMARK_CHARS = 32 * 1024
|
||||
// Why: the daemon auto-resumes a pause after its 5s lost-resume failsafe. If
|
||||
// pending is still above HIGH after that window, the pause must be re-asserted
|
||||
// or a sustained flood would run unthrottled after the first failsafe fires.
|
||||
export const PRODUCER_PAUSE_REASSERT_INTERVAL_MS = 5_000
|
||||
|
||||
export type ProducerFlowControlTransport = {
|
||||
pauseProducer: (id: string) => void
|
||||
resumeProducer: (id: string) => void
|
||||
}
|
||||
|
||||
export class PtyProducerFlowController {
|
||||
private transport: ProducerFlowControlTransport
|
||||
private highWatermarkChars: number
|
||||
private lowWatermarkChars: number
|
||||
private reassertIntervalMs: number
|
||||
private pausedAtByPty = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
transport: ProducerFlowControlTransport,
|
||||
opts: {
|
||||
highWatermarkChars?: number
|
||||
lowWatermarkChars?: number
|
||||
reassertIntervalMs?: number
|
||||
} = {}
|
||||
) {
|
||||
this.transport = transport
|
||||
this.highWatermarkChars = opts.highWatermarkChars ?? PRODUCER_FLOW_HIGH_WATERMARK_CHARS
|
||||
this.lowWatermarkChars = opts.lowWatermarkChars ?? PRODUCER_FLOW_LOW_WATERMARK_CHARS
|
||||
this.reassertIntervalMs = opts.reassertIntervalMs ?? PRODUCER_PAUSE_REASSERT_INTERVAL_MS
|
||||
}
|
||||
|
||||
/** Reports the current pending chars for a PTY. Fires pause exactly once at
|
||||
* the HIGH crossing (re-asserted only after the failsafe interval) and
|
||||
* resume exactly once when pending drains below LOW. */
|
||||
update(id: string, pendingChars: number): void {
|
||||
const pausedAt = this.pausedAtByPty.get(id)
|
||||
if (pausedAt === undefined) {
|
||||
if (pendingChars > this.highWatermarkChars) {
|
||||
this.pausedAtByPty.set(id, Date.now())
|
||||
this.safePause(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (pendingChars < this.lowWatermarkChars) {
|
||||
this.pausedAtByPty.delete(id)
|
||||
this.safeResume(id)
|
||||
return
|
||||
}
|
||||
if (
|
||||
pendingChars > this.highWatermarkChars &&
|
||||
Date.now() - pausedAt >= this.reassertIntervalMs
|
||||
) {
|
||||
this.pausedAtByPty.set(id, Date.now())
|
||||
this.safePause(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resumes a PTY if it was paused. For teardown paths (exit, kill) where
|
||||
* the pending bookkeeping is being dropped rather than drained. */
|
||||
release(id: string): void {
|
||||
if (this.pausedAtByPty.delete(id)) {
|
||||
this.safeResume(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resumes every paused PTY. For wholesale bookkeeping wipes (window
|
||||
* destroyed) — a local PTY left paused here would stay wedged forever. */
|
||||
releaseAll(): void {
|
||||
// Deleting the visited entry during Map key iteration is spec-safe.
|
||||
for (const id of this.pausedAtByPty.keys()) {
|
||||
this.release(id)
|
||||
}
|
||||
}
|
||||
|
||||
isPaused(id: string): boolean {
|
||||
return this.pausedAtByPty.has(id)
|
||||
}
|
||||
|
||||
// Why swallow: pause/resume are optimizations riding the terminal data
|
||||
// path — a provider throw must never break delivery or exit handling.
|
||||
private safePause(id: string): void {
|
||||
try {
|
||||
this.transport.pauseProducer(id)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
private safeResume(id: string): void {
|
||||
try {
|
||||
this.transport.resumeProducer(id)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
+1954
-144
File diff suppressed because it is too large
Load Diff
+1080
-112
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ const {
|
||||
applyElectronProxySettingsMock,
|
||||
browserWindowGetAllWindowsMock,
|
||||
handleMock,
|
||||
onMock,
|
||||
previewGhosttyImportMock,
|
||||
previewWarpThemeImportMock,
|
||||
prepareLocalWorktreeRootsForReposMock,
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
applyElectronProxySettingsMock: vi.fn(),
|
||||
browserWindowGetAllWindowsMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
onMock: vi.fn(),
|
||||
previewGhosttyImportMock: vi.fn(),
|
||||
previewWarpThemeImportMock: vi.fn(),
|
||||
prepareLocalWorktreeRootsForReposMock: vi.fn(),
|
||||
@@ -22,7 +24,7 @@ const {
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
|
||||
ipcMain: { handle: handleMock },
|
||||
ipcMain: { handle: handleMock, on: onMock },
|
||||
nativeTheme: { themeSource: 'system' }
|
||||
}))
|
||||
|
||||
@@ -70,6 +72,7 @@ const store = {
|
||||
describe('registerSettingsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handleMock.mockClear()
|
||||
onMock.mockClear()
|
||||
applyAppIconMock.mockClear()
|
||||
applyElectronProxySettingsMock.mockClear()
|
||||
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
|
||||
@@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => {
|
||||
expect(channels).toContain('settings:previewGhosttyImport')
|
||||
})
|
||||
|
||||
it('answers the synchronous settings read with the persisted settings', () => {
|
||||
// Why: panes can bind PTYs before async hydration; the side-effect
|
||||
// authority kill switch needs the persisted value synchronously.
|
||||
store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false })
|
||||
registerSettingsHandlers(store as never)
|
||||
|
||||
const listener = onMock.mock.calls.find(
|
||||
(call) => call[0] === 'settings:get-sync'
|
||||
)?.[1] as (event: { returnValue: unknown }) => void
|
||||
expect(listener).toBeTypeOf('function')
|
||||
|
||||
const event = { returnValue: undefined as unknown }
|
||||
listener(event)
|
||||
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
|
||||
})
|
||||
|
||||
it('registers settings:previewWarpThemeImport handler', () => {
|
||||
registerSettingsHandlers(store as never)
|
||||
const channels = handleMock.mock.calls.map((call) => call[0])
|
||||
|
||||
@@ -67,6 +67,15 @@ export function registerSettingsHandlers(
|
||||
return store.getSettings()
|
||||
})
|
||||
|
||||
// Why: terminal panes can bind PTYs before async settings hydration
|
||||
// completes. The side-effect authority kill switch is consulted once at
|
||||
// transport creation, so the renderer needs the persisted value
|
||||
// synchronously or pre-hydration bindings would always pick main authority
|
||||
// (terminal-side-effect-authority.md, migration switch).
|
||||
ipcMain.on('settings:get-sync', (event) => {
|
||||
event.returnValue = store.getSettings()
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
|
||||
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
|
||||
// Why: Floating Workspace grants are trusted only when written by the
|
||||
|
||||
@@ -6935,6 +6935,169 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
})
|
||||
|
||||
it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: null
|
||||
}
|
||||
const sshRepo = {
|
||||
...localRepo,
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: sshRepo.path,
|
||||
head: 'main',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/remote/feature-wt',
|
||||
head: 'feature',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]),
|
||||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||||
}
|
||||
store.getRepo.mockReturnValue(localRepo)
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-shared::/remote/feature-wt',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when duplicate repo ids are deleted without a host', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-shared::/remote/feature-wt'
|
||||
})
|
||||
).rejects.toThrow('Repo not found: repo-shared')
|
||||
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inspects hooks on the requested host when repo ids collide', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||||
const fsProvider = {
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
content: 'scripts:\n archive: remote-cleanup',
|
||||
isBinary: false
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } })
|
||||
|
||||
await expect(
|
||||
handlers['hooks:check'](null, {
|
||||
repoId: 'repo-shared',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
hasHooks: true,
|
||||
hooks: { scripts: { archive: 'remote-cleanup' } },
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails hook inspection closed when duplicate repo ids omit the host', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
|
||||
await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({
|
||||
status: 'error',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
|
||||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not coalesce forget requests for the same id on different hosts', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
let finishFirst!: () => void
|
||||
killAllProcessesForWorktreeMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFirst = () =>
|
||||
resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||||
|
||||
const first = handlers['worktrees:forgetLocal'](null, {
|
||||
worktreeId: 'repo-shared::/same/path',
|
||||
hostId: 'local'
|
||||
}) as Promise<unknown>
|
||||
await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:forgetLocal'](null, {
|
||||
worktreeId: 'repo-shared::/same/path',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
).resolves.toEqual({})
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
finishFirst()
|
||||
await expect(first).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('preserves the branch on remove for worktrees created from an existing local branch', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
@@ -7257,14 +7420,16 @@ describe('registerWorktreeHandlers', () => {
|
||||
await mkdir(adminWorktreePath, { recursive: true })
|
||||
await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`)
|
||||
await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`)
|
||||
store.getRepo.mockReturnValue({
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: repoPath,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: null
|
||||
})
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath)
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ createdAt: Date.now() }))
|
||||
|
||||
@@ -7298,14 +7463,16 @@ describe('registerWorktreeHandlers', () => {
|
||||
await mkdir(adminWorktreePath, { recursive: true })
|
||||
await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`)
|
||||
await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`)
|
||||
store.getRepo.mockReturnValue({
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: repoPath,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: null
|
||||
})
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath)
|
||||
store.getWorktreeMeta.mockReturnValue(
|
||||
makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' })
|
||||
@@ -7527,6 +7694,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}) as Promise<unknown>
|
||||
const second = handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
hostId: 'local',
|
||||
force: true
|
||||
}) as Promise<unknown>
|
||||
|
||||
@@ -7564,6 +7732,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
await expect(
|
||||
handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
hostId: 'local',
|
||||
force: true
|
||||
})
|
||||
).rejects.toThrow('Worktree deletion already in progress')
|
||||
|
||||
+114
-55
@@ -29,6 +29,7 @@ import type {
|
||||
Worktree,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
buildKnownOrcaWorkspaceLayouts,
|
||||
isLegacyRepoForExternalWorktreeVisibility,
|
||||
@@ -101,6 +102,35 @@ import {
|
||||
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
}
|
||||
|
||||
type RemoveWorktreeArgs = {
|
||||
worktreeId: string
|
||||
hostId?: ExecutionHostId
|
||||
force?: boolean
|
||||
skipArchive?: boolean
|
||||
}
|
||||
|
||||
function getRepoForWorktreeRemoval(
|
||||
store: Store,
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): Repo | undefined {
|
||||
const matches = store
|
||||
.getRepos()
|
||||
.filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
|
||||
// Why: deletion must never guess between host owners. Legacy unscoped calls
|
||||
// remain compatible only while the repo id still has one unique owner.
|
||||
if (matches.length === 1) {
|
||||
return matches[0]
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return undefined
|
||||
}
|
||||
const legacyMatch = store.getRepo(repoId)
|
||||
return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId)
|
||||
? legacyMatch
|
||||
: undefined
|
||||
}
|
||||
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
|
||||
@@ -300,6 +330,10 @@ function getWorktreeRemovalOptionsKey(args: { force?: boolean; skipArchive?: boo
|
||||
return `${forceKey}:${archiveKey}`
|
||||
}
|
||||
|
||||
function getWorktreeRemovalInFlightKey(worktreeId: string, hostId?: ExecutionHostId): string {
|
||||
return `${hostId ?? ''}\0${worktreeId}`
|
||||
}
|
||||
|
||||
async function getArchiveHooksForRemoval(repo: Repo): Promise<OrcaHooks | null> {
|
||||
if (!repo.connectionId) {
|
||||
return getEffectiveHooks(repo)
|
||||
@@ -1345,9 +1379,18 @@ export function registerWorktreeHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:remove',
|
||||
async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => {
|
||||
async (_event, args: RemoveWorktreeArgs): Promise<RemoveWorktreeResult> => {
|
||||
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
|
||||
const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
const inFlightKey = getWorktreeRemovalInFlightKey(
|
||||
args.worktreeId,
|
||||
getRepoExecutionHostId(repo)
|
||||
)
|
||||
const optionsKey = getWorktreeRemovalOptionsKey(args)
|
||||
const inFlightRemoval = worktreeRemovalsInFlight.get(args.worktreeId)
|
||||
const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey)
|
||||
if (inFlightRemoval) {
|
||||
if (inFlightRemoval.optionsKey === optionsKey) {
|
||||
return inFlightRemoval.promise
|
||||
@@ -1359,11 +1402,6 @@ export function registerWorktreeHandlers(
|
||||
// target the same worktree concurrently. Share the destructive backend
|
||||
// operation so only one path touches Git and the filesystem.
|
||||
const removal = (async (): Promise<RemoveWorktreeResult> => {
|
||||
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
|
||||
const repo = store.getRepo(repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
if (args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
@@ -1797,12 +1835,12 @@ export function registerWorktreeHandlers(
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
})()
|
||||
worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: removal })
|
||||
worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal })
|
||||
try {
|
||||
return await removal
|
||||
} finally {
|
||||
if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === removal) {
|
||||
worktreeRemovalsInFlight.delete(args.worktreeId)
|
||||
if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) {
|
||||
worktreeRemovalsInFlight.delete(inFlightKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1816,12 +1854,24 @@ export function registerWorktreeHandlers(
|
||||
// no branches, no files are deleted there.
|
||||
ipcMain.handle(
|
||||
'worktrees:forgetLocal',
|
||||
async (_event, args: { worktreeId: string }): Promise<RemoveWorktreeResult> => {
|
||||
async (
|
||||
_event,
|
||||
args: Pick<RemoveWorktreeArgs, 'worktreeId' | 'hostId'>
|
||||
): Promise<RemoveWorktreeResult> => {
|
||||
const { repoId } = parseWorktreeId(args.worktreeId)
|
||||
const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
// Why: share the removal in-flight map (not a separate one) so a concurrent
|
||||
// worktrees:remove and worktrees:forgetLocal on the same id cannot both
|
||||
// mutate metadata. A forget takes no force/skipArchive options.
|
||||
const inFlightKey = getWorktreeRemovalInFlightKey(
|
||||
args.worktreeId,
|
||||
getRepoExecutionHostId(repo)
|
||||
)
|
||||
const optionsKey = 'forget-local'
|
||||
const inFlight = worktreeRemovalsInFlight.get(args.worktreeId)
|
||||
const inFlight = worktreeRemovalsInFlight.get(inFlightKey)
|
||||
if (inFlight) {
|
||||
if (inFlight.optionsKey === optionsKey) {
|
||||
return inFlight.promise
|
||||
@@ -1830,11 +1880,6 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
|
||||
const forget = (async (): Promise<RemoveWorktreeResult> => {
|
||||
const { repoId } = parseWorktreeId(args.worktreeId)
|
||||
const repo = store.getRepo(repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
@@ -1858,12 +1903,12 @@ export function registerWorktreeHandlers(
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return {}
|
||||
})()
|
||||
worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: forget })
|
||||
worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget })
|
||||
try {
|
||||
return await forget
|
||||
} finally {
|
||||
if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === forget) {
|
||||
worktreeRemovalsInFlight.delete(args.worktreeId)
|
||||
if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) {
|
||||
worktreeRemovalsInFlight.delete(inFlightKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1996,49 +2041,63 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hooks:check', async (_event, args: { repoId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
|
||||
if (repo.connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
ipcMain.handle(
|
||||
'hooks:check',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
|
||||
if (!repo) {
|
||||
const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId)
|
||||
// Why: a requested or ambiguous host must not be reported as hook-free;
|
||||
// callers treat inspection errors as "skip", which keeps hook execution fail closed.
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: !result.isBinary,
|
||||
hooks: result.isBinary ? null : parseOrcaYaml(result.content),
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: isENOENT(error) ? 'ok' : 'error',
|
||||
status: args.hostId || repoIdExists ? 'error' : 'ok',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = has ? loadHooks(repo.path) : null
|
||||
// Why: when a newer Orca version adds a top-level key to `orca.yaml`, older
|
||||
// versions that don't recognise it return null and show "could not be parsed".
|
||||
// Detecting well-formed but unrecognised keys lets the UI suggest updating
|
||||
// instead of implying the file is broken.
|
||||
const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path)
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: has,
|
||||
hooks,
|
||||
mayNeedUpdate
|
||||
if (repo.connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: !result.isBinary,
|
||||
hooks: result.isBinary ? null : parseOrcaYaml(result.content),
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: isENOENT(error) ? 'ok' : 'error',
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = has ? loadHooks(repo.path) : null
|
||||
// Why: when a newer Orca version adds a top-level key to `orca.yaml`, older
|
||||
// versions that don't recognise it return null and show "could not be parsed".
|
||||
// Detecting well-formed but unrecognised keys lets the UI suggest updating
|
||||
// instead of implying the file is broken.
|
||||
const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path)
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: has,
|
||||
hooks,
|
||||
mayNeedUpdate
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:createIssueCommandRunner',
|
||||
|
||||
@@ -92,6 +92,8 @@ describe('LocalPtyProvider', () => {
|
||||
onExit: ReturnType<typeof vi.fn>
|
||||
write: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
process: string
|
||||
pid: number
|
||||
@@ -138,6 +140,8 @@ describe('LocalPtyProvider', () => {
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
exitCb?.({ exitCode: -1 })
|
||||
}),
|
||||
@@ -896,6 +900,39 @@ describe('LocalPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('producer flow control', () => {
|
||||
it('pauses and resumes the node-pty process directly', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
provider.pauseProducer(id)
|
||||
expect(mockProc.pause).toHaveBeenCalledTimes(1)
|
||||
provider.resumeProducer(id)
|
||||
expect(mockProc.resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is a no-op for unknown PTY ids', () => {
|
||||
expect(() => {
|
||||
provider.pauseProducer('nonexistent')
|
||||
provider.resumeProducer('nonexistent')
|
||||
}).not.toThrow()
|
||||
expect(mockProc.pause).not.toHaveBeenCalled()
|
||||
expect(mockProc.resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows node-pty throws from a torn-down PTY', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
mockProc.pause.mockImplementation(() => {
|
||||
throw new Error('read EIO')
|
||||
})
|
||||
mockProc.resume.mockImplementation(() => {
|
||||
throw new Error('read EIO')
|
||||
})
|
||||
expect(() => {
|
||||
provider.pauseProducer(id)
|
||||
provider.resumeProducer(id)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('kills the PTY process', async () => {
|
||||
// Why: capture the spy reference before shutdown triggers onExit →
|
||||
|
||||
@@ -842,6 +842,26 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
ptyProcesses.get(id)?.resize(cols, rows)
|
||||
}
|
||||
|
||||
// Why: node-pty pause() stops reading the pty master fd, so the kernel
|
||||
// buffer fills and a flooding child blocks on write — true producer
|
||||
// backpressure. Best-effort: a PTY torn down mid-call must never throw
|
||||
// into the flow-control path.
|
||||
pauseProducer(id: string): void {
|
||||
try {
|
||||
ptyProcesses.get(id)?.pause()
|
||||
} catch {
|
||||
/* PTY already destroyed */
|
||||
}
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
try {
|
||||
ptyProcesses.get(id)?.resume()
|
||||
} catch {
|
||||
/* PTY already destroyed */
|
||||
}
|
||||
}
|
||||
|
||||
// Why: node-pty caches the last winsize it applied on the IPty handle, so its
|
||||
// cols/rows are the authoritative applied size (node-pty clamps invalid dims
|
||||
// and a resize on a dead handle is a no-op, neither of which the requested
|
||||
|
||||
@@ -17,6 +17,9 @@ vi.mock('electron', () => ({
|
||||
on: onMock,
|
||||
removeHandler: removeHandlerMock,
|
||||
removeAllListeners: removeAllListenersMock
|
||||
},
|
||||
powerMonitor: {
|
||||
on: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -21,9 +21,38 @@ import type { CommitMessageDraftContext } from '../../shared/commit-message-gene
|
||||
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
|
||||
|
||||
// ─── PTY Provider ───────────────────────────────────────────────────
|
||||
|
||||
/** Notification-bearing fact a thinning transport detected while it held
|
||||
* scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */
|
||||
export type PtyTransientFact =
|
||||
| { kind: 'bell' }
|
||||
| { kind: 'command-finished'; exitCode: number | null }
|
||||
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
|
||||
| { kind: '2031-subscribe' }
|
||||
|
||||
export type PtyBackgroundStreamEvent =
|
||||
| { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string }
|
||||
| { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number }
|
||||
| { id: string; kind: 'transientFact'; fact: PtyTransientFact }
|
||||
|
||||
export type PtyProviderBufferSnapshot = {
|
||||
data: string
|
||||
/** Authoritative normal buffer captured beside an alternate-screen frame. */
|
||||
scrollbackAnsi?: string
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string | null
|
||||
lastTitle?: string
|
||||
seq: number
|
||||
source: 'headless'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
}
|
||||
|
||||
export type PtySpawnOptions = {
|
||||
cols: number
|
||||
rows: number
|
||||
@@ -83,6 +112,11 @@ export type PtySpawnResult = {
|
||||
* writing the snapshot so ANSI cursor positions land correctly. */
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
|
||||
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
|
||||
* (terminal-query-authority.md §kitty). Never replayed into a renderer
|
||||
* xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
/** True when the spawn reattached to an existing daemon session. */
|
||||
isReattach?: boolean
|
||||
/** True when the reattached session uses the alternate screen buffer
|
||||
@@ -119,6 +153,36 @@ export type IPtyProvider = {
|
||||
hasPty?: (id: string) => boolean
|
||||
write(id: string, data: string): void
|
||||
resize(id: string, cols: number, rows: number): void
|
||||
/**
|
||||
* Producer-side flow control: stop/restart reading the underlying PTY so a
|
||||
* flooding child blocks on write (kernel backpressure) instead of growing
|
||||
* main-process buffers. Best-effort and optional — providers that cannot
|
||||
* pause (SSH relay, legacy daemon protocols) omit these or no-op silently,
|
||||
* and callers must keep functioning without them (the pending-output cap
|
||||
* still bounds memory when pause is unavailable).
|
||||
*/
|
||||
pauseProducer?: (id: string) => void
|
||||
resumeProducer?: (id: string) => void
|
||||
/**
|
||||
* Hidden-delivery hint: the renderer has no visible view for this PTY, so
|
||||
* the provider's transport may keep-tail thin this PTY's monitoring stream
|
||||
* under backlog (bytes nobody is watching must not bury a visible pane's
|
||||
* echo). Best-effort and optional, like pauseProducer.
|
||||
*/
|
||||
setPtyBackgrounded?: (id: string, background: boolean) => void
|
||||
/**
|
||||
* Facts a thinning transport interleaves with onData, in byte order:
|
||||
* scan-authority handoff markers, keep-tail gaps, and the transient facts
|
||||
* (bell/command-finished/pr-link/2031) it detected in bytes it was allowed
|
||||
* to drop. Only transports that thin implement it.
|
||||
*/
|
||||
onBackgroundStreamEvent?: (callback: (payload: PtyBackgroundStreamEvent) => void) => () => void
|
||||
/** Authoritative provider-owned model snapshot. Daemon providers expose this
|
||||
* after their monitoring stream gaps; other providers may omit it. */
|
||||
getBufferSnapshot?: (
|
||||
id: string,
|
||||
opts?: { scrollbackRows?: number }
|
||||
) => Promise<PtyProviderBufferSnapshot | null>
|
||||
/**
|
||||
* The size the PTY has ACTUALLY applied, not the last size requested.
|
||||
* resize() is fire-and-forget for remote providers (daemon/SSH `notify`),
|
||||
@@ -145,7 +209,9 @@ export type IPtyProvider = {
|
||||
listProcesses(): Promise<PtyProcessInfo[]>
|
||||
getDefaultShell(): Promise<string>
|
||||
getProfiles(): Promise<{ name: string; path: string }[]>
|
||||
onData(callback: (payload: { id: string; data: string }) => void): () => void
|
||||
onData(
|
||||
callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
|
||||
): () => void
|
||||
onReplay(callback: (payload: { id: string; data: string }) => void): () => void
|
||||
onExit(callback: (payload: { id: string; code: number }) => void): () => void
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from './orca-runtime'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_MAX_BYTES,
|
||||
@@ -1625,6 +1626,318 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a stale leaf when a woken agent PTY is re-keyed to a new leaf on renderer reload', async () => {
|
||||
const runtime = createRuntime()
|
||||
const tabId = 'tab-1'
|
||||
// Why: the agent holds a pre-allocated ORCA_TERMINAL_HANDLE, so its PTY has a
|
||||
// handleByPtyId entry — the exact condition the reload preservation loop keys on.
|
||||
runtime.preAllocateHandleForPty('pty-agent')
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude',
|
||||
activeLeafId: 'leaf-old',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-old',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-agent'
|
||||
}
|
||||
]
|
||||
})
|
||||
const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(before.terminals).toHaveLength(1)
|
||||
|
||||
// Simulate agent sleep + mobile wake: the renderer reloads and cold-restores
|
||||
// the pane under a NEW leafId while the SAME agent PTY stays live.
|
||||
runtime.markRendererReloading(TEST_WINDOW_ID)
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude',
|
||||
activeLeafId: 'leaf-new',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-new',
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-agent'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
// Before the fix the stale leaf-old survived beside leaf-new — two leaves on
|
||||
// ONE PTY — so both adopted the same ptyId-keyed handle and paired clients
|
||||
// crashed with a duplicate React key (`term_…`). Now only the live leaf remains.
|
||||
expect(after.terminals).toHaveLength(1)
|
||||
expect(after.terminals[0].ptyId).toBe('pty-agent')
|
||||
// The shared handle must NOT have been invalidated — it belongs to leaf-new now.
|
||||
await expect(runtime.showTerminal(after.terminals[0].handle)).resolves.toMatchObject({
|
||||
ptyId: 'pty-agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('still preserves a CLI agent leaf when the reloaded renderer has not rebound its PTY', async () => {
|
||||
const runtime = createRuntime()
|
||||
const tabId = 'tab-1'
|
||||
runtime.preAllocateHandleForPty('pty-agent')
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude',
|
||||
activeLeafId: 'leaf-old',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-old',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-agent'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect((await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).terminals).toHaveLength(1)
|
||||
|
||||
// Renderer reloads but has NOT yet rebound the pane (empty graph). The live
|
||||
// CLI agent PTY + exported handle must survive (the original preserve intent).
|
||||
runtime.markRendererReloading(TEST_WINDOW_ID)
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
|
||||
const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(after.terminals).toHaveLength(1)
|
||||
expect(after.terminals[0].ptyId).toBe('pty-agent')
|
||||
})
|
||||
|
||||
it('invalidates a re-keyed leaf-unique handle so in-flight waiters fail fast', async () => {
|
||||
const runtime = createRuntime()
|
||||
const tabId = 'tab-1'
|
||||
// No preAllocateHandleForPty: a plain terminal's handle is leaf-unique, so a
|
||||
// re-key leaves it with no next owner and it must go stale immediately.
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Shell',
|
||||
activeLeafId: 'leaf-old',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-old',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-plain'
|
||||
}
|
||||
]
|
||||
})
|
||||
const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(before.terminals).toHaveLength(1)
|
||||
const staleHandle = before.terminals[0].handle
|
||||
const waiting = runtime.waitForTerminal(staleHandle, { condition: 'exit', timeoutMs: 30_000 })
|
||||
|
||||
// Re-key WITHOUT a renderer reload (e.g. a pane moved across tabs) while the
|
||||
// same PTY stays live under a new leaf.
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Shell',
|
||||
activeLeafId: 'leaf-new',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-new',
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-plain'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// The waiter must fail fast, not hang until timeout on a dead leaf.
|
||||
await expect(waiting).rejects.toThrow('terminal_handle_stale')
|
||||
const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(after.terminals).toHaveLength(1)
|
||||
expect(after.terminals[0].handle).not.toBe(staleHandle)
|
||||
})
|
||||
|
||||
it('keeps a live CLI waiter pending when a re-keyed shared handle transfers to the live leaf', async () => {
|
||||
const runtime = createRuntime()
|
||||
const tabId = 'tab-1'
|
||||
// The counterpart to the leaf-unique case above: a pre-allocated
|
||||
// ORCA_TERMINAL_HANDLE is ptyId-keyed and shared, so re-keying its live PTY to
|
||||
// a new leaf (a pane moved across tabs, no renderer reload) must hand the
|
||||
// handle to the live leaf WITHOUT rejecting the agent's in-flight waiter. This
|
||||
// pins the shared-handle branch so collapsing it into a blanket
|
||||
// invalidateLeafHandle can't silently strand a CLI `terminal wait`. (A renderer
|
||||
// reload is a separate path — markRendererReloading stales every waiter itself.)
|
||||
runtime.preAllocateHandleForPty('pty-agent')
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude',
|
||||
activeLeafId: 'leaf-old',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-old',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-agent'
|
||||
}
|
||||
]
|
||||
})
|
||||
const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
const sharedHandle = before.terminals[0].handle
|
||||
const abort = new AbortController()
|
||||
let settled: 'resolved' | 'rejected' | null = null
|
||||
const waiting = runtime
|
||||
.waitForTerminal(sharedHandle, {
|
||||
condition: 'exit',
|
||||
timeoutMs: 30_000,
|
||||
signal: abort.signal
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
settled = 'resolved'
|
||||
},
|
||||
() => {
|
||||
settled = 'rejected'
|
||||
}
|
||||
)
|
||||
|
||||
// Re-key WITHOUT a renderer reload while the same agent PTY stays live under a
|
||||
// new leaf.
|
||||
runtime.syncWindowGraph(TEST_WINDOW_ID, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude',
|
||||
activeLeafId: 'leaf-new',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'leaf-new',
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-agent'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Let any synchronous stale-handle rejection propagate.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
// The shared handle belongs to leaf-new now, so the CLI's live waiter must stay
|
||||
// pending — a blanket invalidateLeafHandle would reject it here as stale.
|
||||
expect(settled).toBeNull()
|
||||
// The same handle still resolves to the live PTY under the new leaf.
|
||||
await expect(runtime.showTerminal(sharedHandle)).resolves.toMatchObject({
|
||||
ptyId: 'pty-agent'
|
||||
})
|
||||
|
||||
// Abort only for teardown; the assertion above already proved it was pending.
|
||||
abort.abort()
|
||||
await waiting
|
||||
expect(settled).toBe('rejected')
|
||||
})
|
||||
|
||||
it('emits one mobile session terminal tab per live PTY even if two tabs resolve to it', () => {
|
||||
const runtime = createRuntime()
|
||||
const internals = runtime as unknown as {
|
||||
recordPtyWorktree: (
|
||||
ptyId: string,
|
||||
worktreeId: string,
|
||||
state?: Record<string, unknown>
|
||||
) => void
|
||||
mobileSessionTabsByWorktree: Map<string, unknown>
|
||||
getMobileSessionTabsForWorktree: (worktreeId: string) => {
|
||||
tabs: { type: string; terminal?: string | null }[]
|
||||
}
|
||||
}
|
||||
// Two unclaimed live PTYs on the worktree; the worktree-only fallback binds
|
||||
// either to a leafless tab that references it.
|
||||
internals.recordPtyWorktree('pty-shared', TEST_WORKTREE_ID, { connected: true })
|
||||
internals.recordPtyWorktree('pty-other', TEST_WORKTREE_ID, { connected: true })
|
||||
|
||||
const terminalTab = (leafId: string, ptyId: string) => ({
|
||||
type: 'terminal' as const,
|
||||
id: `term_dup::${leafId}`,
|
||||
parentTabId: 'term_dup',
|
||||
leafId,
|
||||
ptyId,
|
||||
title: 'Agent',
|
||||
isActive: leafId === 'leaf-new'
|
||||
})
|
||||
|
||||
// Two records (a stale headless leaf + the live leaf) that both resolve to
|
||||
// the SAME live PTY — the renderer-graph origin the leaf fix can't reach.
|
||||
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, {
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer:test:1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'term_dup::leaf-new',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [terminalTab('leaf-new', 'pty-shared'), terminalTab('leaf-old', 'pty-shared')]
|
||||
})
|
||||
const deduped = internals.getMobileSessionTabsForWorktree(TEST_WORKTREE_ID)
|
||||
const dedupedTerminals = deduped.tabs.filter((tab) => tab.type === 'terminal')
|
||||
expect(dedupedTerminals).toHaveLength(1)
|
||||
expect(dedupedTerminals[0].terminal).toBeTruthy()
|
||||
|
||||
// Split siblings own DISTINCT PTYs, so they must never be collapsed.
|
||||
internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, {
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer:test:2',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'term_dup::leaf-new',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [terminalTab('leaf-new', 'pty-shared'), terminalTab('leaf-old', 'pty-other')]
|
||||
})
|
||||
const split = internals.getMobileSessionTabsForWorktree(TEST_WORKTREE_ID)
|
||||
expect(split.tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps targeted terminal lists from adopting controller PTYs for other worktrees', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
...MOCK_GIT_WORKTREES,
|
||||
@@ -6163,6 +6476,654 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves tui-idle when a completion title is coalesced with the next working title', async () => {
|
||||
// Why: node-pty + the main batch window can coalesce "task done" and the
|
||||
// next task's working title into one chunk. A last-title reader never
|
||||
// sees the intermediate idle and the waiter hangs (issue #1083 class).
|
||||
const runtime = createRuntime()
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const wait = runtime.waitForTerminal(terminal.handle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 1_000
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101)
|
||||
|
||||
await expect(wait).resolves.toMatchObject({
|
||||
handle: terminal.handle,
|
||||
condition: 'tui-idle',
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => {
|
||||
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
|
||||
// cursor-agent re-emits its bare native title on internal redraws while
|
||||
// still working; it must not stomp the synthesized working title.
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101)
|
||||
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: '⠋ Cursor Agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale working title after 3s of title-less output', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData(ptyId, 'output without a title\r\n', 101)
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: 'Codex working'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: 'Codex'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the stale-title timer when the PTY exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData(ptyId, 'output without a title\r\n', 101)
|
||||
runtime.onPtyExit(ptyId, 0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
// The dead session keeps its factual last title — the disposed tracker's
|
||||
// stale-title rewrite must not fire into the retained record.
|
||||
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
|
||||
title: 'Codex working'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps stale-title timers isolated per PTY', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a`
|
||||
const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b`
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{ id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' },
|
||||
{ id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' }
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
|
||||
runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100)
|
||||
// Only A receives title-less output, so only A's stale timer arms.
|
||||
runtime.onPtyData(ptyA, 'output without a title\r\n', 101)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
const { terminals } = await runtime.listTerminals()
|
||||
expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' })
|
||||
expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({
|
||||
title: 'Aider working'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ──
|
||||
describe('terminal side-effect fact channel', () => {
|
||||
function createSideEffectRuntime(): {
|
||||
runtime: OrcaRuntimeService
|
||||
batches: TerminalSideEffectBatch[]
|
||||
} {
|
||||
const batches: TerminalSideEffectBatch[] = []
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
onTerminalSideEffects: (batch) => batches.push(batch)
|
||||
})
|
||||
return { runtime, batches }
|
||||
}
|
||||
|
||||
it('emits one batched event per chunk with facts in byte order and attribution', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07'
|
||||
runtime.onPtyData('pty-1', chunk, 100)
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(batches[0]).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
seq: chunk.length,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:1'
|
||||
})
|
||||
expect(batches[0].replay).toBeUndefined()
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' },
|
||||
{ kind: 'agent-idle', title: 'Codex done' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps per-PTY ordering across chunks and accumulates seq', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title'])
|
||||
expect(batches[0].seq).toBeLessThan(batches[1].seq)
|
||||
})
|
||||
|
||||
it('emits nothing for chunks without derived facts', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// Plain output, a BEL-terminated non-title OSC split across chunks, and
|
||||
// an Orca status payload: none of these is a title/bell/agent fact.
|
||||
runtime.onPtyData('pty-1', 'plain output\r\n', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]7;file://host', 101)
|
||||
runtime.onPtyData('pty-1', '/tmp\x07', 102)
|
||||
runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103)
|
||||
|
||||
expect(batches).toEqual([])
|
||||
})
|
||||
|
||||
it('emits the stale-working-title rewrite as between-chunk fact batches', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
runtime.onPtyData('pty-1', 'output without a title\r\n', 101)
|
||||
batches.length = 0
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
// Timer facts fire outside a chunk, so each emits immediately —
|
||||
// still strictly ordered per PTY. They carry staleWorkingTitleClear:
|
||||
// the renderer must clear state without scheduling a task-complete
|
||||
// notification main's unthrottled timer did not earn.
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex',
|
||||
rawTitle: 'Codex',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ingests synthetic title frames without touching the byte pipeline', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
|
||||
// The synthesized spinner classifies as working — agent facts derive
|
||||
// from synthetic frames the same as from real bytes.
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
// Synthetic frames are fabricated by main: they must not advance the
|
||||
// metered output sequence the renderer ACK budget is based on.
|
||||
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('carries the synthetic permission BEL as a bell fact', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07')
|
||||
|
||||
expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' })
|
||||
expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' })
|
||||
})
|
||||
|
||||
it('emits command-finished facts with best-effort exit codes across chunk splits', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100)
|
||||
expect(batches).toEqual([])
|
||||
runtime.onPtyData('pty-1', '0\x07prompt $ ', 101)
|
||||
runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{ kind: 'command-finished', exitCode: 130 },
|
||||
{ kind: 'command-finished', exitCode: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('emits pr-link facts once per URL with batch attribution', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100)
|
||||
runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101)
|
||||
// Repeated URL: deduped per PTY, like the renderer byte detector.
|
||||
runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102)
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(batches[0]).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
expect(batches[0].facts).toEqual([
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 42
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/43',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 43
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('emits 2031-subscribe facts across chunk splits', () => {
|
||||
// Why: hidden-delivery-gated views never receive the bytes — this fact
|
||||
// is their only signal to send the DECSET 2031 color-scheme reply.
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b[?20', 100)
|
||||
expect(batches).toEqual([])
|
||||
runtime.onPtyData('pty-1', '31h', 101)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
|
||||
})
|
||||
|
||||
it('prefers the tracked title over the renderer snapshot lastTitle', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'visible content',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// The renderer xterm never saw the synthetic frame (it no longer
|
||||
// rides pty:data), so its serializer reports a stale title.
|
||||
lastTitle: 'stale shell title'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
|
||||
const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
expect(snapshot?.source).toBe('renderer')
|
||||
expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent')
|
||||
})
|
||||
|
||||
it('prefers the tracked title over the headless emulator lastTitle', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100)
|
||||
// The hook-driven idle frame lands only in main's tracker — the
|
||||
// emulator never sees fabricated bytes (invariant 5).
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
|
||||
|
||||
const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
expect(snapshot?.source).toBe('headless')
|
||||
expect(snapshot?.lastTitle).toBe('Codex ready')
|
||||
})
|
||||
|
||||
it('returns a title-only replay snapshot and never historical attention', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100)
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
replay: true,
|
||||
facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }]
|
||||
})
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the cursor-agent literal from record-fallback snapshots', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
// Simulate a record title restored by a path that bypassed the tracker
|
||||
// (the tracker itself refuses to store the bare native title).
|
||||
const records = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null }>
|
||||
}
|
||||
).ptysById
|
||||
records.get('pty-1')!.lastOscTitle = 'Cursor Agent'
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits the chunk agentStatus events before its side-effect batch', () => {
|
||||
// Cross-channel contract order per chunk: status → titles → bell.
|
||||
const order: string[] = []
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
onTerminalAgentStatus: () => order.push('agentStatus:set'),
|
||||
onTerminalSideEffects: () => order.push('pty:sideEffect')
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
'\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07',
|
||||
100
|
||||
)
|
||||
|
||||
expect(order).toEqual(['agentStatus:set', 'pty:sideEffect'])
|
||||
})
|
||||
|
||||
it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
vi.spyOn(
|
||||
runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean },
|
||||
'applyTrackedPtyTitle'
|
||||
).mockImplementationOnce(() => {
|
||||
throw new Error('tracker boom')
|
||||
})
|
||||
|
||||
const first = '\x1b]0;Codex working\x07'
|
||||
expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom')
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
expect(batches).toHaveLength(2)
|
||||
expect(batches[0].seq).toBe(first.length)
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }
|
||||
])
|
||||
// The next chunk's batch carries only its own facts (the throw aborted
|
||||
// the first chunk's agent-tracker pass, so no working state was kept).
|
||||
expect(batches[1].seq).toBeGreaterThan(batches[0].seq)
|
||||
expect(batches[1].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;split ti', 100)
|
||||
// An 80ms spinner tick lands between the two halves of the real OSC.
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
// Continuation: this BEL terminates the real OSC — it is NOT a bell.
|
||||
runtime.onPtyData('pty-1', 'tle\x07', 101)
|
||||
// A later standalone BEL is a real bell and must not be swallowed.
|
||||
runtime.onPtyData('pty-1', 'ready\x07', 102)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
})
|
||||
|
||||
it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
const touchSpy = vi.spyOn(
|
||||
runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void },
|
||||
'touchMobileSessionSnapshotsForPty'
|
||||
)
|
||||
|
||||
for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) {
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
}
|
||||
// Five ticks with the same de-spinnered title: one snapshot fan-out.
|
||||
expect(touchSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07')
|
||||
expect(touchSpy).toHaveBeenCalledTimes(2)
|
||||
// Raw record titles still track every frame for worktree ps/mobile tabs.
|
||||
expect(
|
||||
(
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-1')?.lastOscTitle
|
||||
).toBe('Cursor ready')
|
||||
})
|
||||
|
||||
it('seeds the lazily created tracker from the daemon-snapshot title', async () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored scrollback\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
lastTitle: 'Codex working'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// First live chunk creates the tracker cold and kicks off hydration;
|
||||
// the snapshot seed must land in the already-created tracker.
|
||||
runtime.onPtyData('pty-1', 'plain output without a title\n', 100)
|
||||
await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
batches.length = 0
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
// Without the seed the tracker never saw 'working', so this idle title
|
||||
// could not produce a completion fact.
|
||||
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
|
||||
kind: 'agent-idle',
|
||||
title: 'Codex done'
|
||||
})
|
||||
})
|
||||
|
||||
it('arms the stale-title timer for a seeded working title', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'restored scrollback\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
lastTitle: 'Codex working'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
// Settle the async daemon-snapshot hydration that seeds the tracker.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
runtime.onPtyData('pty-1', 'still no title\n', 101)
|
||||
batches.length = 0
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex',
|
||||
rawTitle: 'Codex',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits command-code-working facts only after the banner arms the scrape', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// Generic status words without the Command Code banner must not arm.
|
||||
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100)
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101)
|
||||
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102)
|
||||
|
||||
expect(batches.at(-1)).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
expect(batches.at(-1)?.facts).toEqual([
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' }
|
||||
])
|
||||
})
|
||||
|
||||
it('emits a command-code-done fact when the idle composer returns', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100)
|
||||
runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101)
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
'\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...',
|
||||
102
|
||||
)
|
||||
|
||||
expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }])
|
||||
})
|
||||
|
||||
it('arms the Command Code scrape from the noted spawn command', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
// Mirrors the renderer detector's startupCommand fast-arm: no banner
|
||||
// needed when main saw the launch command at spawn time.
|
||||
runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust')
|
||||
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
|
||||
kind: 'command-code-working',
|
||||
prompt: 'Fix the spinner'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'renderer scrollback\n',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// The renderer xterm never saw the synthetic hook frame (it no longer
|
||||
// rides pty:data), so its serializer reports the pre-agent title.
|
||||
lastTitle: 'stale shell title'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeBuffer,
|
||||
hasRendererSerializer: () => true,
|
||||
getSize: () => ({ cols: 80, rows: 24 })
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07')
|
||||
// First live chunk kicks off renderer hydration; awaiting the snapshot
|
||||
// below settles the seed write chain.
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
|
||||
|
||||
const leaves = (
|
||||
runtime as unknown as { leaves: Map<string, { lastOscTitle: string | null }> }
|
||||
).leaves
|
||||
// The seed must not stomp the leaf record (worktree ps status source)
|
||||
// back to the renderer's stale title.
|
||||
expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns OSC titles from headless main terminal snapshots', async () => {
|
||||
const runtime = createRuntime()
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
@@ -6421,6 +7382,16 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(serializeBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advances the absolute output sequence across a daemon stream gap', () => {
|
||||
const runtime = createRuntime()
|
||||
runtime.onPtyData('pty-gap', 'before', Date.now())
|
||||
|
||||
runtime.notePtyDataGap('pty-gap', 4096)
|
||||
runtime.onPtyData('pty-gap', 'after', Date.now())
|
||||
|
||||
expect(runtime.getPtyOutputSequence('pty-gap')).toBe('before'.length + 4096 + 'after'.length)
|
||||
})
|
||||
|
||||
it('emits explicit OSC 9999 agent status from runtime PTY data', () => {
|
||||
const statuses: RuntimeTerminalAgentStatusEvent[] = []
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
|
||||
+1035
-168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendNormalizedToTailBuffer,
|
||||
appendNormalizedToMultilineTailBufferUnwindowed
|
||||
} from './orca-runtime'
|
||||
|
||||
// Differential guard for the windowed redraw tail path: the public
|
||||
// appendNormalizedToTailBuffer routes vertical-control chunks through a
|
||||
// suffix-windowed wrapper (findings log 2026-07-03 — the unwindowed path was
|
||||
// O(tail) per chunk and dominated main's event loop under agent-TUI floods).
|
||||
// This fuzz asserts the windowed result is byte-identical to the reference
|
||||
// implementation across randomized tails and redraw chunks.
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomTail(rng: () => number, maxLines: number): string[] {
|
||||
const count = Math.floor(rng() * maxLines)
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const base = `line ${i} ${'x'.repeat(Math.floor(rng() * 40))}`
|
||||
// Trailing whitespace included deliberately: the reference implementation
|
||||
// trims every row on each call, so the windowed prefix must match.
|
||||
return rng() < 0.3 ? `${base} ` : base
|
||||
})
|
||||
}
|
||||
|
||||
function randomRedrawChunk(rng: () => number): string {
|
||||
const parts: string[] = []
|
||||
const ops = 1 + Math.floor(rng() * 12)
|
||||
for (let i = 0; i < ops; i++) {
|
||||
const roll = rng()
|
||||
if (roll < 0.2) {
|
||||
parts.push(`\x1b[${1 + Math.floor(rng() * 12)}A`)
|
||||
} else if (roll < 0.3) {
|
||||
parts.push(`\x1b[${Math.floor(rng() * 3)}J`)
|
||||
} else if (roll < 0.4) {
|
||||
parts.push(`\x1b[${Math.floor(rng() * 3)}K`)
|
||||
} else if (roll < 0.5) {
|
||||
parts.push('\r')
|
||||
} else if (roll < 0.6) {
|
||||
parts.push(`\x1b[${1 + Math.floor(rng() * 30)}G`)
|
||||
} else if (roll < 0.7) {
|
||||
parts.push('\n')
|
||||
} else if (roll < 0.75) {
|
||||
parts.push('')
|
||||
} else {
|
||||
parts.push(`text${Math.floor(rng() * 100)} ${'y'.repeat(Math.floor(rng() * 20))}`)
|
||||
}
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
describe('windowed redraw tail equivalence', () => {
|
||||
it('matches the unwindowed reference across 500 randomized cases', () => {
|
||||
const rng = mulberry32(42)
|
||||
for (let round = 0; round < 500; round++) {
|
||||
const tail = randomTail(rng, round % 5 === 0 ? 2100 : 300)
|
||||
const partial = rng() < 0.5 ? `partial ${'z'.repeat(Math.floor(rng() * 30))}` : ''
|
||||
const redrawCursor =
|
||||
rng() < 0.3 ? { rowFromEnd: Math.floor(rng() * 20), column: Math.floor(rng() * 40) } : null
|
||||
// Why the guaranteed cursor-up: the public function routes to the
|
||||
// multiline (windowed) path only for vertical-control chunks; chunks
|
||||
// without one take the single-line fast path, which is out of scope.
|
||||
const chunk = `\x1b[${1 + Math.floor(rng() * 4)}A${randomRedrawChunk(rng)}`
|
||||
|
||||
const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor)
|
||||
// Reference path over the full tail.
|
||||
const expected = appendNormalizedToMultilineTailBufferUnwindowed(
|
||||
tail,
|
||||
partial.slice(-4000),
|
||||
chunk,
|
||||
partial.length > 4000,
|
||||
redrawCursor
|
||||
)
|
||||
|
||||
expect(actual.lines, `round ${round} lines`).toEqual(expected.lines)
|
||||
expect(actual.partialLine, `round ${round} partial`).toBe(expected.partialLine)
|
||||
expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor)
|
||||
expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated)
|
||||
expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
|
||||
const TERMINAL_OUTPUT_FLUSH_MS = 5
|
||||
// Why: output batches become binary stream payloads; byte size is the transport cost.
|
||||
const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024
|
||||
// Why: remote clients can apply output pressure without pausing runtime PTY ingestion.
|
||||
const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024
|
||||
const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
|
||||
// Why: pending output is held for later binary frames, so cap the encoded
|
||||
// payload bytes rather than UTF-16 code units.
|
||||
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
|
||||
@@ -60,6 +63,7 @@ type SnapshotFrameOptions = {
|
||||
|
||||
type SerializedSnapshot = {
|
||||
data: string
|
||||
scrollbackAnsi?: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
@@ -82,7 +86,13 @@ type TerminalMultiplexStream = {
|
||||
ptyId: string
|
||||
client: TerminalViewportClient | undefined
|
||||
isMobile: boolean
|
||||
ackOutput: boolean
|
||||
ackInFlightBytes: number
|
||||
buffering: boolean
|
||||
ackPendingOutput: TerminalOutputFrameChunk[]
|
||||
ackPendingOutputBytes: number
|
||||
ackPendingOutputOverflowed: boolean
|
||||
ackRecoverySnapshotInFlight: boolean
|
||||
pendingOutput: TerminalOutputChunk[]
|
||||
pendingOutputBytes: number
|
||||
pendingOutputOverflowed: boolean
|
||||
@@ -362,6 +372,26 @@ function getOutputAfterSnapshotSeq(
|
||||
return chunk.data.slice(snapshotSeq - chunkStartSeq)
|
||||
}
|
||||
|
||||
function appendAckPendingOutput(
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
): void {
|
||||
stream.ackPendingOutput.push(chunk)
|
||||
stream.ackPendingOutputBytes += chunk.bytes.byteLength
|
||||
let omittedChunkCount = 0
|
||||
while (
|
||||
stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES &&
|
||||
omittedChunkCount < stream.ackPendingOutput.length
|
||||
) {
|
||||
stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength
|
||||
omittedChunkCount += 1
|
||||
}
|
||||
if (omittedChunkCount > 0) {
|
||||
stream.ackPendingOutput.splice(0, omittedChunkCount)
|
||||
stream.ackPendingOutputOverflowed = true
|
||||
}
|
||||
}
|
||||
|
||||
function trimPendingOutputToBudget(
|
||||
pendingOutput: TerminalOutputChunk[],
|
||||
pendingOutputBytes: number
|
||||
@@ -388,6 +418,43 @@ function measureTerminalStreamByteLength(
|
||||
return measureClipboardTextByteLength(data, options)
|
||||
}
|
||||
|
||||
function trimPendingOutputCoveredBySnapshot(
|
||||
pendingOutput: TerminalOutputChunk[],
|
||||
snapshotSeq: number | undefined
|
||||
): { chunks: TerminalOutputChunk[]; bytes: number } {
|
||||
if (typeof snapshotSeq !== 'number') {
|
||||
return {
|
||||
chunks: pendingOutput,
|
||||
bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0)
|
||||
}
|
||||
}
|
||||
const chunks: TerminalOutputChunk[] = []
|
||||
let bytes = 0
|
||||
for (const chunk of pendingOutput) {
|
||||
const chunkSeq = chunk.meta?.seq
|
||||
const rawLength = chunk.meta?.rawLength ?? chunk.data.length
|
||||
if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) {
|
||||
chunks.push(chunk)
|
||||
bytes += chunk.bytes
|
||||
continue
|
||||
}
|
||||
const startSeq = chunkSeq - rawLength
|
||||
if (snapshotSeq >= chunkSeq) {
|
||||
continue
|
||||
}
|
||||
if (snapshotSeq <= startSeq) {
|
||||
chunks.push(chunk)
|
||||
bytes += chunk.bytes
|
||||
continue
|
||||
}
|
||||
const data = chunk.data.slice(snapshotSeq - startSeq)
|
||||
const slicedBytes = terminalStreamByteLength(data)
|
||||
chunks.push({ data, bytes: slicedBytes, meta: undefined })
|
||||
bytes += slicedBytes
|
||||
}
|
||||
return { chunks, bytes }
|
||||
}
|
||||
|
||||
function terminalStreamByteLength(data: string): number {
|
||||
return measureTerminalStreamByteLength(data).byteLength
|
||||
}
|
||||
@@ -436,13 +503,12 @@ async function serializeBudgetedRequestedSnapshot(
|
||||
if (!serialized) {
|
||||
return null
|
||||
}
|
||||
const overByteBudget = terminalStreamByteLengthExceeds(
|
||||
serialized.data,
|
||||
REQUESTED_SNAPSHOT_BYTE_BUDGET
|
||||
)
|
||||
const data = (serialized.scrollbackAnsi ?? '') + serialized.data
|
||||
const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)
|
||||
if (!overByteBudget || rows === 0) {
|
||||
return {
|
||||
...serialized,
|
||||
data,
|
||||
scrollbackRows: rows,
|
||||
truncatedByByteBudget: rows < requestedRows || overByteBudget
|
||||
}
|
||||
@@ -491,7 +557,14 @@ async function serializeBudgetedMobileSnapshot(
|
||||
): Promise<SerializedSnapshot> {
|
||||
if (!isMobile) {
|
||||
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 })
|
||||
return serialized ? { ...serialized, scrollbackRows: 0, truncatedByByteBudget: false } : null
|
||||
return serialized
|
||||
? {
|
||||
...serialized,
|
||||
data: (serialized.scrollbackAnsi ?? '') + serialized.data,
|
||||
scrollbackRows: 0,
|
||||
truncatedByByteBudget: false
|
||||
}
|
||||
: null
|
||||
}
|
||||
const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0]
|
||||
for (const rows of candidates) {
|
||||
@@ -499,13 +572,12 @@ async function serializeBudgetedMobileSnapshot(
|
||||
if (!serialized) {
|
||||
return null
|
||||
}
|
||||
const overByteBudget = terminalStreamByteLengthExceeds(
|
||||
serialized.data,
|
||||
MOBILE_SNAPSHOT_BYTE_BUDGET
|
||||
)
|
||||
const data = (serialized.scrollbackAnsi ?? '') + serialized.data
|
||||
const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET)
|
||||
if (!overByteBudget || rows === 0) {
|
||||
return {
|
||||
...serialized,
|
||||
data,
|
||||
scrollbackRows: rows,
|
||||
truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget
|
||||
}
|
||||
@@ -749,7 +821,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
|
||||
type: z.enum(['mobile', 'desktop']).default('desktop')
|
||||
})
|
||||
.optional(),
|
||||
viewport: TerminalViewport.optional()
|
||||
viewport: TerminalViewport.optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
ackOutput: z.literal(1).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const TerminalMultiplexAckFrame = z.object({
|
||||
bytes: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
const TerminalMultiplexSnapshotRequestFrame = z.object({
|
||||
@@ -1224,6 +1305,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
let closed = false
|
||||
let cursor = 0
|
||||
const streams = new Map<number, TerminalMultiplexStream>()
|
||||
let ackTotalInFlightBytes = 0
|
||||
let resolveMultiplex = (): void => {}
|
||||
const multiplexClosed = new Promise<void>((resolve) => {
|
||||
resolveMultiplex = resolve
|
||||
@@ -1269,6 +1351,136 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
})
|
||||
)
|
||||
}
|
||||
const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => {
|
||||
if (!stream.ackOutput) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES &&
|
||||
ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES
|
||||
)
|
||||
}
|
||||
const sendAckGatedOutput = (
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
): void => {
|
||||
sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
|
||||
if (stream.ackOutput) {
|
||||
stream.ackInFlightBytes += chunk.bytes.byteLength
|
||||
ackTotalInFlightBytes += chunk.bytes.byteLength
|
||||
}
|
||||
}
|
||||
const queueOrSendOutput = (
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
): void => {
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
stream.ackPendingOutputOverflowed ||
|
||||
stream.ackPendingOutput.length > 0 ||
|
||||
!canSendAckGatedOutput(stream, chunk.bytes.byteLength)
|
||||
) {
|
||||
appendAckPendingOutput(stream, chunk)
|
||||
return
|
||||
}
|
||||
sendAckGatedOutput(stream, chunk)
|
||||
}
|
||||
const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise<void> => {
|
||||
if (
|
||||
closed ||
|
||||
streams.get(stream.streamId) !== stream ||
|
||||
stream.ackRecoverySnapshotInFlight
|
||||
) {
|
||||
return
|
||||
}
|
||||
stream.ackRecoverySnapshotInFlight = true
|
||||
try {
|
||||
const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0)
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
const size = runtime.getTerminalSize(stream.ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(stream.ptyId)
|
||||
// Why: dropped ACK-pending output means live frames are no longer a
|
||||
// complete replay. Send a fresh model snapshot before resuming output.
|
||||
// Why: truncated marks an unusable snapshot, and clients discard
|
||||
// those. The recovery snapshot must be applied to cover dropped
|
||||
// output, so it is only truncated when serialization failed.
|
||||
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
|
||||
kind: 'scrollback',
|
||||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
displayMode,
|
||||
reason: 'ack-pending-overflow',
|
||||
seq: serialized?.seq,
|
||||
source: serialized?.source,
|
||||
truncated: !serialized,
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
data: serialized?.data ?? ''
|
||||
})
|
||||
if (serialized && typeof serialized.seq === 'number') {
|
||||
// Why: retained chunks queued before the snapshot serialized are
|
||||
// already contained in it; replaying them would duplicate output.
|
||||
const snapshotSeq = serialized.seq
|
||||
const retained = stream.ackPendingOutput.filter(
|
||||
(chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq)
|
||||
)
|
||||
stream.ackPendingOutput = retained
|
||||
stream.ackPendingOutputBytes = retained.reduce(
|
||||
(total, chunk) => total + chunk.bytes.byteLength,
|
||||
0
|
||||
)
|
||||
}
|
||||
stream.ackPendingOutputOverflowed = false
|
||||
} catch (error) {
|
||||
sendStreamError(
|
||||
stream.streamId,
|
||||
error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.'
|
||||
)
|
||||
} finally {
|
||||
if (streams.get(stream.streamId) === stream) {
|
||||
stream.ackRecoverySnapshotInFlight = false
|
||||
flushAckPendingOutput(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => {
|
||||
if (stream.ackPendingOutputOverflowed) {
|
||||
void sendAckRecoverySnapshot(stream)
|
||||
return
|
||||
}
|
||||
let flushed = 0
|
||||
while (
|
||||
flushed < stream.ackPendingOutput.length &&
|
||||
canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength)
|
||||
) {
|
||||
sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)
|
||||
flushed += 1
|
||||
}
|
||||
if (flushed > 0) {
|
||||
stream.ackPendingOutput.splice(0, flushed)
|
||||
stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce(
|
||||
(total, pending) => total + pending.bytes.byteLength,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
const flushAllAckPendingOutput = (): void => {
|
||||
for (const stream of streams.values()) {
|
||||
flushAckPendingOutput(stream)
|
||||
}
|
||||
}
|
||||
const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => {
|
||||
if (!stream.ackOutput || bytes <= 0) {
|
||||
return
|
||||
}
|
||||
const acknowledged = Math.min(stream.ackInFlightBytes, bytes)
|
||||
stream.ackInFlightBytes -= acknowledged
|
||||
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged)
|
||||
flushAllAckPendingOutput()
|
||||
}
|
||||
const detachStream = (streamId: number, emitEnd: boolean): void => {
|
||||
const stream = streams.get(streamId)
|
||||
if (!stream) {
|
||||
@@ -1276,12 +1488,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
stream.outputBatcher.flush()
|
||||
stream.outputBatcher.dispose()
|
||||
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes)
|
||||
stream.ackInFlightBytes = 0
|
||||
stream.ackPendingOutput = []
|
||||
stream.ackPendingOutputBytes = 0
|
||||
stream.ackPendingOutputOverflowed = false
|
||||
stream.ackRecoverySnapshotInFlight = false
|
||||
stream.unsubscribeData()
|
||||
stream.unsubscribeResize()
|
||||
stream.unsubscribeFit()
|
||||
stream.unsubscribeDriver()
|
||||
stream.unregisterBinaryHandler()
|
||||
streams.delete(streamId)
|
||||
flushAllAckPendingOutput()
|
||||
// Why: release the runtime exit-waiter for this slot (see the field's
|
||||
// note). The .catch below no-ops because the stream is already deleted.
|
||||
stream.exitWaiterAbort.abort()
|
||||
@@ -1314,6 +1533,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
detachStream(stream.streamId, false)
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Ack) {
|
||||
const parsed = TerminalMultiplexAckFrame.safeParse(
|
||||
decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
|
||||
)
|
||||
if (parsed.success) {
|
||||
acknowledgeOutput(stream, parsed.data.bytes)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Input) {
|
||||
const text = decodeTerminalStreamText(frame.payload)
|
||||
if (!text) {
|
||||
@@ -1482,6 +1710,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
return
|
||||
}
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
// Why: a competing subscribe for the same streamId can fully register
|
||||
// while this one awaited the PTY id above. Overwriting it in
|
||||
// `streams` would orphan its data/view-subscriber registrations — a
|
||||
// leaked view subscriber permanently silences the model query
|
||||
// responder (terminal-query-authority.md). Detach it so every
|
||||
// registration stays release-balanced.
|
||||
detachStream(request.streamId, false)
|
||||
|
||||
const ptyId = leaf.ptyId
|
||||
const stream: TerminalMultiplexStream = {
|
||||
@@ -1490,7 +1728,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
ptyId,
|
||||
client: request.client,
|
||||
isMobile,
|
||||
ackOutput: request.capabilities?.ackOutput === 1,
|
||||
ackInFlightBytes: 0,
|
||||
buffering: true,
|
||||
ackPendingOutput: [],
|
||||
ackPendingOutputBytes: 0,
|
||||
ackPendingOutputOverflowed: false,
|
||||
ackRecoverySnapshotInFlight: false,
|
||||
pendingOutput: [],
|
||||
pendingOutputBytes: 0,
|
||||
pendingOutputOverflowed: false,
|
||||
@@ -1506,7 +1750,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
)
|
||||
}
|
||||
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
|
||||
sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
|
||||
queueOrSendOutput(stream, chunk)
|
||||
}
|
||||
}),
|
||||
unsubscribeData: () => {},
|
||||
@@ -1522,7 +1766,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
)
|
||||
|
||||
try {
|
||||
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed || streams.get(request.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
@@ -1532,6 +1776,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
stream.outputBatcher.push(data, meta)
|
||||
})
|
||||
// Why: a multiplexed stream feeds a remote xterm view that answers
|
||||
// terminal queries with view authority; the main model responder
|
||||
// yields while it is attached (terminal-query-authority.md).
|
||||
// Wrapped into unsubscribeData so every detach path releases it.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
stream.unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
|
||||
if (isMobile && request.client?.id) {
|
||||
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
|
||||
@@ -1718,6 +1971,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Why the ownership check: a newer subscribe may own this streamId
|
||||
// now (it detached and released this stream on arrival). Detaching
|
||||
// or erroring the slot here would tear down the successor's live
|
||||
// registrations instead of this stream's.
|
||||
if (streams.get(request.streamId) !== stream) {
|
||||
return
|
||||
}
|
||||
detachStream(request.streamId, false)
|
||||
sendStreamError(request.streamId, error instanceof Error ? error.message : String(error))
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
@@ -1819,9 +2079,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const outputBatcher = createTerminalOutputBatcher((chunk) => {
|
||||
emit({ type: 'data', chunk })
|
||||
})
|
||||
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
outputBatcher.push(data)
|
||||
})
|
||||
// Why: this legacy JSON stream can feed a live xterm view too
|
||||
// (older web/desktop subscribers), so it conservatively registers
|
||||
// as a remote view subscriber. For read-only watchers the cost is
|
||||
// a withheld model reply — the pre-Phase-5 status quo — which is
|
||||
// strictly safer than a double reply under a view consumer.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
const unsubscribeData = (): void => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
outputBatcher.flush()
|
||||
emit({
|
||||
@@ -1861,7 +2131,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// resize re-stream so it only fires on an actual width change.
|
||||
let lastResizeCols: number | undefined
|
||||
let resizeGeneration = 0
|
||||
const pendingOutput: TerminalOutputChunk[] = []
|
||||
let pendingOutput: TerminalOutputChunk[] = []
|
||||
let pendingOutputBytes = 0
|
||||
let pendingOutputOverflowed = false
|
||||
let unsubscribeData = (): void => {}
|
||||
@@ -1975,7 +2245,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
|
||||
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -1996,6 +2266,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
outputBatcher?.push(data, meta)
|
||||
})
|
||||
// Why: binary subscribe streams feed remote xterm views (mobile and
|
||||
// binary-capable desktop clients) that answer queries with view
|
||||
// authority; the main model responder yields while attached.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
|
||||
let read = await runtime.readTerminal(params.terminal)
|
||||
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
|
||||
@@ -2066,6 +2344,55 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// Why: baseline for resize re-stream gating; the client already
|
||||
// rewrapped to these cols via the initial snapshot replay.
|
||||
lastResizeCols = serialized?.cols ?? size?.cols
|
||||
let recoveryAttempts = 0
|
||||
// Why: if the bounded pre-subscribe tail overflowed, only a fresh
|
||||
// model snapshot can cover the dropped middle without replay gaps.
|
||||
while (pendingOutputOverflowed && recoveryAttempts < 2) {
|
||||
pendingOutputOverflowed = false
|
||||
recoveryAttempts += 1
|
||||
const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (!recovery) {
|
||||
break
|
||||
}
|
||||
// Why: without an output seq (renderer-source fallback) covered
|
||||
// chunks cannot be trimmed exactly, and the renderer view may lag
|
||||
// the queued chunks under backpressure. Keep the bounded replay
|
||||
// instead of applying an unverifiable snapshot.
|
||||
if (typeof recovery.seq !== 'number') {
|
||||
break
|
||||
}
|
||||
// Why: shipped mobile clients drop a second scrollback snapshot for
|
||||
// an initialized handle but apply a resized snapshot inline by
|
||||
// re-initializing xterm with fresh scrollback. Omit seq on the wire
|
||||
// so the client's layout-seq staleness filter is not polluted with
|
||||
// output-byte sequences.
|
||||
const recoveryStats = sendSnapshotFrames(sendFrame, {
|
||||
kind: 'resized',
|
||||
cols: recovery.cols,
|
||||
rows: recovery.rows,
|
||||
displayMode,
|
||||
reason: 'pending-output-overflow',
|
||||
source: recovery.source,
|
||||
truncated: false,
|
||||
truncatedByByteBudget: recovery.truncatedByByteBudget,
|
||||
data: recovery.data
|
||||
})
|
||||
console.log('[mobile-terminal-stream] recovery snapshot', {
|
||||
terminal: params.terminal,
|
||||
streamId,
|
||||
reason: 'pending-output-overflow',
|
||||
bytes: recoveryStats.bytes,
|
||||
chunks: recoveryStats.chunks,
|
||||
scrollbackRows: recovery.scrollbackRows,
|
||||
truncatedByByteBudget: recovery.truncatedByByteBudget === true
|
||||
})
|
||||
const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq)
|
||||
pendingOutput = trimmed.chunks
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
}
|
||||
buffering = false
|
||||
const bufferedOutput = pendingOutput.splice(0)
|
||||
if (!initialOutputOverflowed) {
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => {
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: every multiplex stream registers as a remote view subscriber for
|
||||
// Phase-5 query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
// Why: the multiplex subscribe path resolves handles via
|
||||
// resolveLiveLeafForHandle (#7718). Default to a live pty so tests that
|
||||
// only stub the legacy resolveLeafForHandle still bind; tests that need a
|
||||
// null/stale leaf override this explicitly.
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -498,6 +506,708 @@ describe('terminal multiplex RPC', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'snapshot',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
}),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
_: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-gated',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 16,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const output = 'x'.repeat(700 * 1024)
|
||||
dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length })
|
||||
|
||||
const initialOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const initialBytes = initialOutputFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
|
||||
expect(initialOutputFrames.length).toBeGreaterThan(0)
|
||||
const initialOutput = initialOutputFrames
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(initialOutput.length).toBeLessThan(output.length)
|
||||
|
||||
handlers.get(16)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 16,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('still interactive\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'still interactive\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
handlers.get(16)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 16,
|
||||
seq: 3,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytes })
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
const flushedOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('releases shared ACK budget to other stalled multiplex streams', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListeners = new Map<
|
||||
string,
|
||||
(data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn((terminal: string) => ({
|
||||
ptyId: terminal.replace('terminal-', 'pty-')
|
||||
})),
|
||||
resolveLiveLeafForHandle: vi.fn((terminal: string) => ({
|
||||
ptyId: terminal.replace('terminal-', 'pty-')
|
||||
})),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({
|
||||
data: `snapshot-${ptyId}`,
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
ptyId: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListeners.set(ptyId, listener)
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-shared-budget',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
|
||||
const streamIds = [21, 22, 23, 24, 25, 26]
|
||||
for (const streamId of streamIds) {
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: streamId,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId,
|
||||
terminal: `terminal-${streamId - 20}`,
|
||||
client: { id: `desktop-${streamId}`, type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
messages
|
||||
.map((msg) => JSON.parse(msg).result)
|
||||
.filter((result) => result?.type === 'subscribed')
|
||||
).toHaveLength(streamIds.length)
|
||||
)
|
||||
await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length))
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const fillerOutput = 'f'.repeat(480 * 1024)
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
dataListeners.get(`pty-${index}`)?.(fillerOutput, {
|
||||
seq: fillerOutput.length,
|
||||
rawLength: fillerOutput.length
|
||||
})
|
||||
}
|
||||
const stalledOutput = 's'.repeat(700 * 1024)
|
||||
dataListeners.get('pty-5')?.(stalledOutput, {
|
||||
seq: stalledOutput.length,
|
||||
rawLength: stalledOutput.length
|
||||
})
|
||||
dataListeners.get('pty-6')?.(stalledOutput, {
|
||||
seq: stalledOutput.length,
|
||||
rawLength: stalledOutput.length
|
||||
})
|
||||
|
||||
const initialOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const initialBytesByStream = new Map<number, number>()
|
||||
for (const frame of initialOutputFrames) {
|
||||
if (!frame) {
|
||||
continue
|
||||
}
|
||||
initialBytesByStream.set(
|
||||
frame.streamId,
|
||||
(initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength
|
||||
)
|
||||
}
|
||||
const initialBytes = initialOutputFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024)
|
||||
expect(initialBytesByStream.get(21)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(22)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(23)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(24)).toBe(480 * 1024)
|
||||
expect(initialBytesByStream.get(25)).toBeGreaterThan(0)
|
||||
expect(initialBytesByStream.get(26) ?? 0).toBe(0)
|
||||
|
||||
handlers.get(26)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 26,
|
||||
seq: 200,
|
||||
payload: encodeTerminalStreamText('remote-still-interactive\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', {
|
||||
text: 'remote-still-interactive\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
const frameCountBeforeAck = binaryFrames.length
|
||||
handlers.get(21)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 21,
|
||||
seq: 201,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.slice(frameCountBeforeAck)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => {
|
||||
if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
).toBe(true)
|
||||
)
|
||||
const framesAfterAck = binaryFrames
|
||||
.slice(frameCountBeforeAck)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const snapshotStartIndex = framesAfterAck.findIndex((frame) => {
|
||||
if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
const outputFramesAfterAck = framesAfterAck.filter(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.Output
|
||||
)
|
||||
const bytesAfterAckByStream = new Map<number, number>()
|
||||
for (const frame of outputFramesAfterAck) {
|
||||
if (!frame) {
|
||||
continue
|
||||
}
|
||||
bytesAfterAckByStream.set(
|
||||
frame.streamId,
|
||||
(bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength
|
||||
)
|
||||
}
|
||||
expect(snapshotStartIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
framesAfterAck
|
||||
.filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output)
|
||||
.every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex)
|
||||
).toBe(true)
|
||||
expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0)
|
||||
expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0)
|
||||
expect(
|
||||
outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 })
|
||||
.mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
_: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-overflow',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 17,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const output = 'x'.repeat(3 * 1024 * 1024)
|
||||
dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length })
|
||||
|
||||
const initialOutputFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
const initialBytes = initialOutputFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
|
||||
|
||||
handlers.get(17)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 17,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('still interactive\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'still interactive\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
binaryFrames.splice(0)
|
||||
handlers.get(17)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 17,
|
||||
seq: 3,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytes })
|
||||
})
|
||||
)!
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => {
|
||||
if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
).toBe(true)
|
||||
)
|
||||
const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
const recoveryStartIndex = drainFrames.findIndex((frame) => {
|
||||
if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) {
|
||||
return false
|
||||
}
|
||||
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
|
||||
return payload?.reason === 'ack-pending-overflow'
|
||||
})
|
||||
const firstOutputAfterAckIndex = drainFrames.findIndex(
|
||||
(frame) => frame?.opcode === TerminalStreamOpcode.Output
|
||||
)
|
||||
expect(recoveryStartIndex).toBeGreaterThanOrEqual(0)
|
||||
// Why: clients discard truncated snapshots; a usable recovery snapshot
|
||||
// must not be marked truncated or the dropped output gap is permanent.
|
||||
expect(
|
||||
decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload)
|
||||
?.truncated
|
||||
).toBe(false)
|
||||
expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex)
|
||||
expect(
|
||||
drainFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('recovered snapshot')
|
||||
|
||||
const outputBytesAfterRecovery = drainFrames
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('trims recovery-covered ACK pending output instead of replaying it', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const floodedChars = 3 * 1024 * 1024
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 })
|
||||
// Why: the recovery snapshot seq covers the entire flood, so every
|
||||
// retained pending chunk is already contained in the snapshot.
|
||||
.mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn(
|
||||
(
|
||||
_: string,
|
||||
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}
|
||||
),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateDesktopViewport: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-ack-trim',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 31,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const output = 'x'.repeat(floodedChars)
|
||||
dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars })
|
||||
const initialBytes = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
|
||||
|
||||
binaryFrames.splice(0)
|
||||
handlers.get(31)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 31,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamJson({ bytes: initialBytes })
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
expect(
|
||||
framesAfterRecovery
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('recovered snapshot')
|
||||
// Why: every retained chunk is covered by the recovery snapshot seq;
|
||||
// replaying any of them would duplicate snapshot content.
|
||||
expect(
|
||||
framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
).toEqual([])
|
||||
|
||||
binaryFrames.splice(0)
|
||||
const fresh = 'fresh-after-recovery\r\n'
|
||||
dataListenerRef.current?.(fresh, {
|
||||
seq: floodedChars + fresh.length,
|
||||
rawLength: fresh.length
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
const freshOutput = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(freshOutput).toBe(fresh)
|
||||
})
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
@@ -1472,6 +2182,238 @@ describe('terminal multiplex RPC', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
// Why: a leaked registration permanently suppresses the model query
|
||||
// responder (terminal-query-authority.md) — the count must return to 0.
|
||||
let viewSubscriberCount = 0
|
||||
let leafResolved = false
|
||||
let resolveFirstWait: (ptyId: string) => void = () => {}
|
||||
// Why: the multiplex subscribe path resolves via resolveLiveLeafForHandle
|
||||
// (#7718); null makes subscribe A block in waitForLeafPtyId until B resolves.
|
||||
const resolveLeaf = (): { ptyId: string | null } =>
|
||||
leafResolved ? { ptyId: 'pty-1' } : { ptyId: null }
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn(resolveLeaf),
|
||||
resolveLiveLeafForHandle: vi.fn(resolveLeaf),
|
||||
waitForLeafPtyId: vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFirstWait = resolve
|
||||
})
|
||||
),
|
||||
registerRemoteTerminalViewSubscriber: vi.fn(() => {
|
||||
viewSubscriberCount += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
viewSubscriberCount -= 1
|
||||
}
|
||||
}
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-overwrite',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
const sendSubscribe = (): void => {
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 7,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId)
|
||||
// then resolves the leaf directly and fully registers.
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled())
|
||||
leafResolved = true
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
// A resumes and takes the slot; B's registration must be released, not
|
||||
// orphaned by the overwrite.
|
||||
resolveFirstWait('pty-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
handlers.get(7)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: 7,
|
||||
seq: 2,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)!
|
||||
)
|
||||
expect(viewSubscriberCount).toBe(0)
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-overwrite')?.()
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('keeps an evicted subscribe error from detaching the successor stream', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
let viewSubscriberCount = 0
|
||||
const mobileSubscribeWaiters: {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
}[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
// Why: the multiplex subscribe path resolves the leaf via
|
||||
// resolveLiveLeafForHandle (#7718), so it must return a live pty here.
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
registerRemoteTerminalViewSubscriber: vi.fn(() => {
|
||||
viewSubscriberCount += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
viewSubscriberCount -= 1
|
||||
}
|
||||
}
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
handleMobileSubscribe: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve, reject) => {
|
||||
mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject })
|
||||
})
|
||||
),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-evicted-error',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
const sendSubscribe = (): void => {
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 9,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
// A registers, then blocks in handleMobileSubscribe. B (same streamId)
|
||||
// evicts A on arrival and completes its own registration.
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1))
|
||||
sendSubscribe()
|
||||
await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2))
|
||||
mobileSubscribeWaiters[1]!.resolve()
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
|
||||
1
|
||||
)
|
||||
)
|
||||
expect(viewSubscriberCount).toBe(1)
|
||||
|
||||
// A's pending await now rejects. The evicted stream must not detach the
|
||||
// successor that owns the slot.
|
||||
mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(viewSubscriberCount).toBe(1)
|
||||
|
||||
cleanups.get('terminal-multiplex:conn-evicted-error')?.()
|
||||
await dispatchPromise
|
||||
expect(viewSubscriberCount).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => {
|
||||
// Why: after a reconnect a client can resubscribe with a handle whose
|
||||
// pane now hosts a different PTY. Binding the stream anyway would mirror
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -346,13 +349,6 @@ describe('terminal subscribe buffering', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
const subscribed = messages
|
||||
.map((msg) => JSON.parse(msg).result)
|
||||
.find((result) => result?.type === 'subscribed')
|
||||
expect(subscribed).toMatchObject({
|
||||
type: 'subscribed',
|
||||
truncated: false
|
||||
})
|
||||
const snapshotStart = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)
|
||||
@@ -367,21 +363,35 @@ describe('terminal subscribe buffering', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('bounds legacy binary output queued while the initial snapshot is serializing', async () => {
|
||||
it('recovers binary output overflow queued while the initial snapshot is serializing', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: { current?: (data: string) => void } = {}
|
||||
const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const snapshotResolvers: ((value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
|
||||
snapshotResolves.push(resolve)
|
||||
new Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}>((resolve) => {
|
||||
snapshotResolvers.push(resolve)
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
@@ -427,34 +437,54 @@ describe('terminal subscribe buffering', () => {
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
const shiftSpy = vi.spyOn(Array.prototype, 'shift')
|
||||
let seq = 0
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`)
|
||||
const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}`
|
||||
seq += data.length
|
||||
dataListenerRef.current?.(data, { seq, rawLength: data.length })
|
||||
}
|
||||
const shiftCallCount = shiftSpy.mock.calls.length
|
||||
shiftSpy.mockRestore()
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
|
||||
snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 })
|
||||
snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' })
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
|
||||
snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 })
|
||||
snapshotResolvers[1]?.({
|
||||
data: 'recovered after overflow\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq,
|
||||
source: 'headless'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const output = binaryFrames
|
||||
const decodedFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
|
||||
const snapshotStarts = decodedFrames.filter(
|
||||
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))
|
||||
// Why one snapshot: overflow during the initial serialize is recovered
|
||||
// INLINE (drop pending, re-read, re-serialize) before anything is sent,
|
||||
// so the client's first scrollback snapshot is already current. The
|
||||
// 'resized'/pending-output-overflow follow-up path remains only for
|
||||
// overflow that begins after the initial snapshot went out.
|
||||
expect(decodedStarts).toEqual([expect.objectContaining({ kind: 'scrollback', seq })])
|
||||
const snapshotText = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
const output = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
expect(output.length).toBeLessThanOrEqual(256 * 1024)
|
||||
expect(output).toBe('')
|
||||
const snapshotPayload = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(snapshotPayload).toBe('399')
|
||||
expect(output).not.toContain('000')
|
||||
expect(output).not.toContain('399')
|
||||
expect(snapshotText).toContain('recovered after overflow')
|
||||
expect(shiftCallCount).toBe(0)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
@@ -612,4 +642,129 @@ describe('terminal subscribe buffering', () => {
|
||||
runtime.cleanupSubscription('terminal-1:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('applies inline overflow recovery when the snapshot has no output seq', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const dataListenerRef: {
|
||||
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
} = {}
|
||||
const snapshotResolvers: ((value: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
}>((resolve) => {
|
||||
snapshotResolvers.push(resolve)
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => {
|
||||
dataListenerRef.current = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
updateMobileViewport: vi.fn().mockResolvedValue(false)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-buffered-no-seq',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
|
||||
let seq = 0
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}`
|
||||
seq += data.length
|
||||
dataListenerRef.current?.(data, { seq, rawLength: data.length })
|
||||
}
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
|
||||
snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' })
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
|
||||
// Why applying is safe without a seq here: inline recovery serialized
|
||||
// AFTER dropping the overflowed pending queue, so the snapshot covers
|
||||
// those chunks by construction — no seq-based trimming is needed.
|
||||
snapshotResolvers[1]?.({
|
||||
data: 'renderer fallback snapshot\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
source: 'renderer'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const decodedFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
|
||||
const snapshotStarts = decodedFrames.filter(
|
||||
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
|
||||
)
|
||||
// Why seq 1 (layout seq): a no-output-seq snapshot falls back to the
|
||||
// layout seq on the wire; the recovered data still ships as the first
|
||||
// and only scrollback snapshot.
|
||||
expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([
|
||||
expect.objectContaining({ kind: 'scrollback', seq: 1 })
|
||||
])
|
||||
const snapshotText = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
expect(snapshotText).toContain('renderer fallback snapshot')
|
||||
const output = decodedFrames
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.Output)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
// Why empty: the overflowed pending queue was dropped before the
|
||||
// covering snapshot was serialized; nothing needs replay.
|
||||
expect(output).toBe('')
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamText,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../shared/terminal-stream-protocol'
|
||||
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
||||
import { DeviceRegistry } from './device-registry'
|
||||
|
||||
@@ -2760,6 +2769,206 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const writes: { terminal: string; text: string }[] = []
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'multiplex-background-pty' })
|
||||
.mockResolvedValueOnce({ id: 'multiplex-active-pty' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: (ptyId, data) => {
|
||||
writes.push({ terminal: ptyId, text: data })
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
const phoneOffer = server.createPairingOffer({
|
||||
address: '127.0.0.1',
|
||||
name: 'phone',
|
||||
scope: 'mobile'
|
||||
})
|
||||
expect(phoneOffer.available).toBe(true)
|
||||
if (!phoneOffer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
const pairing = parsePairingCode(phoneOffer.pairingUrl)
|
||||
expect(pairing).toBeTruthy()
|
||||
if (!pairing) {
|
||||
throw new Error('Pairing URL did not parse')
|
||||
}
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const laptopEndpoint = metadata!.transports[0]!.endpoint
|
||||
const laptopAuthToken = metadata!.authToken
|
||||
const worktree = 'id:repo-1::/tmp/worktree-a'
|
||||
const backgroundLeafId = '11111111-1111-4111-8111-111111111111'
|
||||
const activeLeafId = '22222222-2222-4222-8222-222222222222'
|
||||
const backgroundCreateResponse = await sendRequest(laptopEndpoint, {
|
||||
id: 'laptop_create_background',
|
||||
authToken: laptopAuthToken,
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree,
|
||||
command: 'background',
|
||||
tabId: 'multiplex-background-tab',
|
||||
leafId: backgroundLeafId
|
||||
}
|
||||
})
|
||||
const activeCreateResponse = await sendRequest(laptopEndpoint, {
|
||||
id: 'laptop_create_active',
|
||||
authToken: laptopAuthToken,
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree,
|
||||
command: 'active',
|
||||
tabId: 'multiplex-active-tab',
|
||||
leafId: activeLeafId,
|
||||
activate: true
|
||||
}
|
||||
})
|
||||
const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } })
|
||||
.terminal
|
||||
const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } })
|
||||
.terminal
|
||||
|
||||
const responses: Record<string, unknown>[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const onError = vi.fn()
|
||||
const subscription = await subscribeRemoteRuntimeRequest(
|
||||
pairing,
|
||||
'terminal.multiplex',
|
||||
{},
|
||||
15_000,
|
||||
{
|
||||
onResponse: (response) => responses.push(response as Record<string, unknown>),
|
||||
onBinary: (bytes) => binaryFrames.push(bytes),
|
||||
onError
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
responses.some(
|
||||
(response) => (response.result as { type?: string } | undefined)?.type === 'ready'
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 1,
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 21,
|
||||
terminal: backgroundTerminal.handle,
|
||||
client: { id: 'desktop-background', type: 'desktop' },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 2,
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 22,
|
||||
terminal: activeTerminal.handle,
|
||||
client: { id: 'desktop-active', type: 'desktop' },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
const subscribedStreamIds = responses
|
||||
.map((response) => response.result as { type?: string; streamId?: number } | undefined)
|
||||
.filter((result) => result?.type === 'subscribed')
|
||||
.map((result) => result?.streamId)
|
||||
expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22]))
|
||||
})
|
||||
binaryFrames.splice(0)
|
||||
|
||||
const backgroundOutput = 'B'.repeat(700 * 1024)
|
||||
runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1)
|
||||
await vi.waitFor(() => {
|
||||
const backgroundFrames = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
||||
const backgroundBytes = backgroundFrames.reduce(
|
||||
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
||||
0
|
||||
)
|
||||
expect(backgroundBytes).toBeGreaterThan(0)
|
||||
expect(backgroundBytes).toBeLessThan(backgroundOutput.length)
|
||||
})
|
||||
|
||||
const frameCountBeforeActive = binaryFrames.length
|
||||
runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2)
|
||||
await vi.waitFor(() => {
|
||||
const activeOutput = binaryFrames
|
||||
.slice(frameCountBeforeActive)
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY')
|
||||
})
|
||||
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 3,
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 22,
|
||||
payload: encodeTerminalStreamText('still interactive\r')
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(writes).toContainEqual({
|
||||
terminal: 'multiplex-active-pty',
|
||||
text: 'still interactive\r'
|
||||
})
|
||||
)
|
||||
|
||||
const backgroundBytesBeforeAck = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
subscription.sendBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
seq: 4,
|
||||
opcode: TerminalStreamOpcode.Ack,
|
||||
streamId: 21,
|
||||
payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck })
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
const backgroundBytesAfterAck = binaryFrames
|
||||
.map((frame) => decodeTerminalStreamFrame(frame))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
||||
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
||||
expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck)
|
||||
})
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
subscription.close()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves worktree.ps from the runtime summary builder', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetTerminalModelQueryAuthorityForTest,
|
||||
clearNativeWindowsConptyPty,
|
||||
isNativeWindowsConptyPty,
|
||||
isNativeWindowsLocalPtySpawn,
|
||||
isTerminalModelQueryAuthorityEnabled,
|
||||
markNativeWindowsConptyPty,
|
||||
shouldModelAnswerHiddenPtyQueries
|
||||
} from './terminal-model-query-authority'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
markHiddenRendererPty,
|
||||
setRendererPtyDeliveryInterest
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
|
||||
const ALL_ON = {
|
||||
terminalMainSideEffectAuthority: true,
|
||||
terminalHiddenDeliveryGate: true,
|
||||
terminalModelQueryAuthority: true
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_resetTerminalModelQueryAuthorityForTest()
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
})
|
||||
|
||||
describe('isTerminalModelQueryAuthorityEnabled', () => {
|
||||
it('defaults on, including for absent settings', () => {
|
||||
expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true)
|
||||
expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true)
|
||||
expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true)
|
||||
expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('is an independent off switch for the responder alone', () => {
|
||||
expect(
|
||||
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires both Phase-4 gate switches — no marks exist without them', () => {
|
||||
expect(
|
||||
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false })
|
||||
).toBe(false)
|
||||
expect(
|
||||
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldModelAnswerHiddenPtyQueries', () => {
|
||||
const answer = (ptyId: string, overrides: Record<string, boolean> = {}): boolean =>
|
||||
shouldModelAnswerHiddenPtyQueries({
|
||||
ptyId,
|
||||
settings: { ...ALL_ON, ...overrides },
|
||||
hasRemoteViewSubscriber: false
|
||||
})
|
||||
|
||||
it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => {
|
||||
expect(answer('pty-1')).toBe(false)
|
||||
markHiddenRendererPty('pty-1')
|
||||
expect(answer('pty-1')).toBe(true)
|
||||
expect(answer('pty-other')).toBe(false)
|
||||
})
|
||||
|
||||
it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => {
|
||||
markHiddenRendererPty('pty-1')
|
||||
setRendererPtyDeliveryInterest('pty-1', true)
|
||||
expect(answer('pty-1')).toBe(false)
|
||||
setRendererPtyDeliveryInterest('pty-1', false)
|
||||
expect(answer('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('yields while a remote view subscriber is attached', () => {
|
||||
markHiddenRendererPty('pty-1')
|
||||
expect(
|
||||
shouldModelAnswerHiddenPtyQueries({
|
||||
ptyId: 'pty-1',
|
||||
settings: ALL_ON,
|
||||
hasRemoteViewSubscriber: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays silent under any kill switch', () => {
|
||||
markHiddenRendererPty('pty-1')
|
||||
expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false)
|
||||
expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false)
|
||||
expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => {
|
||||
const base = {
|
||||
connectionId: null,
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: undefined,
|
||||
platform: 'win32' as NodeJS.Platform
|
||||
}
|
||||
|
||||
it('matches local native Windows spawns', () => {
|
||||
expect(isNativeWindowsLocalPtySpawn(base)).toBe(true)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true)
|
||||
expect(
|
||||
isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-Windows hosts', () => {
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects SSH-backed spawns', () => {
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects WSL cwds and WSL shell overrides', () => {
|
||||
expect(
|
||||
isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' })
|
||||
).toBe(false)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false)
|
||||
expect(
|
||||
isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' })
|
||||
).toBe(false)
|
||||
expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-Windows ConPTY spawn record', () => {
|
||||
it('marks, reads, and clears per PTY', () => {
|
||||
expect(isNativeWindowsConptyPty('pty-1')).toBe(false)
|
||||
markNativeWindowsConptyPty('pty-1')
|
||||
expect(isNativeWindowsConptyPty('pty-1')).toBe(true)
|
||||
expect(isNativeWindowsConptyPty('pty-2')).toBe(false)
|
||||
clearNativeWindowsConptyPty('pty-1')
|
||||
expect(isNativeWindowsConptyPty('pty-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Phase 5 of the terminal model/view architecture: main-side terminal query
|
||||
* authority (docs/reference/terminal-query-authority.md).
|
||||
*
|
||||
* The delivery decision is the reply decision: main answers a query iff the
|
||||
* hidden-delivery gate dropped the chunk that carried it. This module owns
|
||||
* the responder kill-switch predicate and the main-side mirror of the
|
||||
* renderer's native-Windows-ConPTY determination, recorded per PTY at spawn
|
||||
* so the runtime emulator can register the DA1 override before byte zero.
|
||||
*/
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
isHiddenPtyDeliveryGateEnabled,
|
||||
shouldDropHiddenRendererPtyData
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
|
||||
export type TerminalModelQueryAuthoritySettings = Pick<
|
||||
GlobalSettings,
|
||||
'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority'
|
||||
>
|
||||
|
||||
/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops
|
||||
* exist without them) plus the Phase-5-specific independent off switch. */
|
||||
export function isTerminalModelQueryAuthorityEnabled(
|
||||
settings: TerminalModelQueryAuthoritySettings | null | undefined
|
||||
): boolean {
|
||||
return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false
|
||||
}
|
||||
|
||||
/** Per-chunk reply-ownership predicate, evaluated once at ingestion in
|
||||
* OrcaRuntimeService.onPtyData — the same module state and tick as the
|
||||
* hidden-gate drop sites, so "chunk dropped" and "main answers" cannot
|
||||
* diverge for live chunks. Remote view subscribers (mobile/web/remote
|
||||
* desktop xterms on the multiplexed stream) keep view authority, so main
|
||||
* yields while one is attached. */
|
||||
export function shouldModelAnswerHiddenPtyQueries(opts: {
|
||||
ptyId: string
|
||||
settings: TerminalModelQueryAuthoritySettings | null | undefined
|
||||
hasRemoteViewSubscriber: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
isTerminalModelQueryAuthorityEnabled(opts.settings) &&
|
||||
!opts.hasRemoteViewSubscriber &&
|
||||
shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings)
|
||||
)
|
||||
}
|
||||
|
||||
/** Main-side mirror of the renderer's isLocalNativeWindowsPty
|
||||
* (windows-pty-compatibility.ts), computed from spawn-time facts: local or
|
||||
* daemon provider (no SSH connection), win32 host, and not a WSL shell. */
|
||||
export function isNativeWindowsLocalPtySpawn(opts: {
|
||||
connectionId: string | null | undefined
|
||||
cwd: string | null | undefined
|
||||
shellOverride: string | null | undefined
|
||||
platform?: NodeJS.Platform
|
||||
}): boolean {
|
||||
if ((opts.platform ?? process.platform) !== 'win32') {
|
||||
return false
|
||||
}
|
||||
if (opts.connectionId) {
|
||||
return false
|
||||
}
|
||||
if (isWslUncPath(opts.cwd ?? '')) {
|
||||
return false
|
||||
}
|
||||
if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records
|
||||
// the determination at spawn, the runtime consults it at emulator creation.
|
||||
// Daemon-adopted PTYs from a previous app run carry no mark — acceptable:
|
||||
// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life.
|
||||
const nativeWindowsConptyPtys = new Set<string>()
|
||||
|
||||
// Why installers: the mark lands after the awaited spawn response, but daemon
|
||||
// stream data (warm-reattach flush) can lazy-create the runtime emulator
|
||||
// first. The runtime registers an installer so marking retrofits the DA1
|
||||
// override onto an existing emulator; installation is idempotent emulator-side.
|
||||
type ConptyDa1OverrideInstaller = (ptyId: string) => void
|
||||
const conptyDa1OverrideInstallers = new Set<ConptyDa1OverrideInstaller>()
|
||||
|
||||
export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void {
|
||||
conptyDa1OverrideInstallers.add(installer)
|
||||
}
|
||||
|
||||
export function markNativeWindowsConptyPty(id: string): void {
|
||||
nativeWindowsConptyPtys.add(id)
|
||||
for (const installer of conptyDa1OverrideInstallers) {
|
||||
installer(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativeWindowsConptyPty(id: string): boolean {
|
||||
return nativeWindowsConptyPtys.has(id)
|
||||
}
|
||||
|
||||
/** Wired into clearProviderPtyState so every PTY teardown path releases the
|
||||
* spawn record. */
|
||||
export function clearNativeWindowsConptyPty(id: string): void {
|
||||
nativeWindowsConptyPtys.delete(id)
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalModelQueryAuthorityForTest(): void {
|
||||
nativeWindowsConptyPtys.clear()
|
||||
conptyDa1OverrideInstallers.clear()
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* Phase 5 model query responder (docs/reference/terminal-query-authority.md):
|
||||
* reply parity through the runtime emulator, the per-chunk ownership matrix,
|
||||
* the main-side replay guard, and the ingestion-time capture race.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
markHiddenRendererPty,
|
||||
setRendererPtyDeliveryInterest,
|
||||
unmarkHiddenRendererPty
|
||||
} from '../ipc/pty-hidden-delivery-gate'
|
||||
import {
|
||||
_resetTerminalModelQueryAuthorityForTest,
|
||||
markNativeWindowsConptyPty
|
||||
} from './terminal-model-query-authority'
|
||||
import {
|
||||
_resetTerminalViewAttributesForTest,
|
||||
setTerminalViewAttributes
|
||||
} from './terminal-view-attribute-store'
|
||||
import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes'
|
||||
|
||||
const settingsState = {
|
||||
terminalMainSideEffectAuthority: true as boolean,
|
||||
terminalHiddenDeliveryGate: true as boolean,
|
||||
terminalModelQueryAuthority: true as boolean
|
||||
}
|
||||
|
||||
const store = {
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: '',
|
||||
terminalMainSideEffectAuthority: settingsState.terminalMainSideEffectAuthority,
|
||||
terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate,
|
||||
terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority
|
||||
})
|
||||
}
|
||||
|
||||
type RendererBufferStub = { data: string; cols: number; rows: number }
|
||||
|
||||
function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const replies: { ptyId: string; data: string }[] = []
|
||||
runtime.setPtyController({
|
||||
write: (ptyId, data) => {
|
||||
replies.push({ ptyId, data })
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
getSize: () => ({ cols: 80, rows: 24 }),
|
||||
resize: () => true,
|
||||
...(opts.rendererBuffer
|
||||
? {
|
||||
hasRendererSerializer: () => true,
|
||||
serializeBuffer: async () => opts.rendererBuffer ?? null
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return { runtime, replies }
|
||||
}
|
||||
|
||||
/** Awaits the per-PTY emulator writeChain so queued chunk links (and the
|
||||
* replies they forward) have settled. */
|
||||
async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise<void> {
|
||||
await runtime.serializeMainTerminalBuffer(ptyId)
|
||||
}
|
||||
|
||||
/** Renderer-pushed attribute snapshot with distinct, pinned slot values so
|
||||
* reply fixtures cannot pass by coincidence. */
|
||||
function viewAttributes(overrides: Partial<TerminalViewAttributes> = {}): TerminalViewAttributes {
|
||||
const ansi = Array.from(
|
||||
{ length: 256 },
|
||||
(_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb
|
||||
)
|
||||
ansi[1] = [0xcc, 0x00, 0x00]
|
||||
return {
|
||||
foreground: [0xd0, 0xd0, 0xd0],
|
||||
background: [0x1e, 0x1e, 0x2e],
|
||||
cursor: [0xff, 0x99, 0x00],
|
||||
ansi,
|
||||
colorSchemeMode: 'dark',
|
||||
cursorStyle: 'bar',
|
||||
cursorBlink: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
_resetTerminalModelQueryAuthorityForTest()
|
||||
_resetTerminalViewAttributesForTest()
|
||||
settingsState.terminalMainSideEffectAuthority = true
|
||||
settingsState.terminalHiddenDeliveryGate = true
|
||||
settingsState.terminalModelQueryAuthority = true
|
||||
})
|
||||
|
||||
describe('reply parity for hidden-dropped chunks', () => {
|
||||
// Expected replies pinned from the design doc and verified against the
|
||||
// bundled @xterm/headless build — the same core the renderer runs, so
|
||||
// parity is structural for static and model-state classes.
|
||||
it.each([
|
||||
['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']],
|
||||
['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']],
|
||||
['DA2', '\x1b[>c', ['\x1b[>0;276;0c']],
|
||||
['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']],
|
||||
['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']],
|
||||
['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']],
|
||||
['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']],
|
||||
['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']],
|
||||
['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']],
|
||||
['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']],
|
||||
['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']],
|
||||
['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']],
|
||||
['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']],
|
||||
['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']],
|
||||
['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']],
|
||||
['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']],
|
||||
['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']],
|
||||
['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']],
|
||||
['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']],
|
||||
['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']],
|
||||
['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']],
|
||||
['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']],
|
||||
['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']],
|
||||
['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']],
|
||||
['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']],
|
||||
['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']],
|
||||
['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']],
|
||||
['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']]
|
||||
])('%s', async (_label, chunk, expectedReplies) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-q')
|
||||
|
||||
runtime.onPtyData('pty-q', chunk, Date.now())
|
||||
await settle(runtime, 'pty-q')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
|
||||
expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['XTWINOPS', '\x1b[14t'],
|
||||
['XTGETTCAP', '\x1bP+q544e\x1b\\'],
|
||||
['DSR ?15n printer status', '\x1b[?15n'],
|
||||
['DSR ?25n UDK status', '\x1b[?25n'],
|
||||
['DSR ?26n keyboard status', '\x1b[?26n'],
|
||||
['DSR ?53n locator status', '\x1b[?53n'],
|
||||
// View-attribute class: silent until the slice-2 renderer attribute push
|
||||
// — a fabricated default would resurrect the default-black OSC-11 bug.
|
||||
['OSC 10 foreground query', '\x1b]10;?\x07'],
|
||||
['OSC 11 background query', '\x1b]11;?\x07'],
|
||||
['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'],
|
||||
['OSC 4 palette query', '\x1b]4;1;?\x07'],
|
||||
['DSR ?996n color-scheme query', '\x1b[?996n']
|
||||
])('stays silent for %s', async (_label, chunk) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-q')
|
||||
|
||||
runtime.onPtyData('pty-q', chunk, Date.now())
|
||||
await settle(runtime, 'pty-q')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reply ownership matrix', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
|
||||
runtime.onPtyData('pty-v', DA1, Date.now())
|
||||
await settle(runtime, 'pty-v')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-i')
|
||||
setRendererPtyDeliveryInterest('pty-i', true)
|
||||
|
||||
runtime.onPtyData('pty-i', DA1, Date.now())
|
||||
await settle(runtime, 'pty-i')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
|
||||
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
|
||||
[
|
||||
'terminalMainSideEffectAuthority',
|
||||
() => (settingsState.terminalMainSideEffectAuthority = false)
|
||||
]
|
||||
])('never answers with kill switch %s off', async (_label, flip) => {
|
||||
flip()
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-k')
|
||||
|
||||
runtime.onPtyData('pty-k', DA1, Date.now())
|
||||
await settle(runtime, 'pty-k')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('yields while a remote view subscriber is attached and resumes on release', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-r')
|
||||
const release = runtime.registerRemoteTerminalViewSubscriber('pty-r')
|
||||
|
||||
runtime.onPtyData('pty-r', DA1, Date.now())
|
||||
await settle(runtime, 'pty-r')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
release()
|
||||
// Releases are idempotent: a double release must not unbalance the count.
|
||||
release()
|
||||
runtime.onPtyData('pty-r', DA1, Date.now())
|
||||
await settle(runtime, 'pty-r')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('counts overlapping remote view subscribers', () => {
|
||||
const { runtime } = createResponderRuntime()
|
||||
const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m')
|
||||
const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m')
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true)
|
||||
releaseA()
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true)
|
||||
releaseB()
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats mobile subscriber records as remote view subscribers', async () => {
|
||||
const { runtime } = createResponderRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 })
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true)
|
||||
})
|
||||
|
||||
it('answers a dropped-chunk query exactly once', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-once')
|
||||
|
||||
runtime.onPtyData('pty-once', DA1, Date.now())
|
||||
await settle(runtime, 'pty-once')
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('main-side replay guard', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-seed')
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`)
|
||||
await settle(runtime, 'pty-seed')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-seed', DA1, Date.now())
|
||||
await settle(runtime, 'pty-seed')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('never answers queries replayed by renderer-buffer hydration', async () => {
|
||||
const { runtime, replies } = createResponderRuntime({
|
||||
rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 }
|
||||
})
|
||||
markHiddenRendererPty('pty-hyd')
|
||||
|
||||
// First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration
|
||||
// seed parses the embedded DA1 but must not forward its reply.
|
||||
runtime.onPtyData('pty-hyd', 'live output', Date.now())
|
||||
await settle(runtime, 'pty-hyd')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => {
|
||||
it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-kitty')
|
||||
|
||||
// Daemon warm-reattach threads modes.kittyKeyboardFlags through the
|
||||
// spawn result into the seed; applying them is a seed-side write and
|
||||
// must answer no one (main-side replay guard).
|
||||
runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, {
|
||||
kittyKeyboardFlags: 5
|
||||
})
|
||||
await settle(runtime, 'pty-kitty')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now())
|
||||
await settle(runtime, 'pty-kitty')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u'])
|
||||
})
|
||||
|
||||
it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-kitty0')
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt')
|
||||
runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now())
|
||||
await settle(runtime, 'pty-kitty0')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingestion-time ownership capture', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('still answers when the hidden mark flips off between ingestion and the async write', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-race')
|
||||
|
||||
runtime.onPtyData('pty-race', DA1, Date.now())
|
||||
// Flip before the queued writeChain link runs: the captured decision wins.
|
||||
unmarkHiddenRendererPty('pty-race')
|
||||
await settle(runtime, 'pty-race')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('stays silent when the hidden mark lands after ingestion', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
|
||||
runtime.onPtyData('pty-race2', DA1, Date.now())
|
||||
markHiddenRendererPty('pty-race2')
|
||||
await settle(runtime, 'pty-race2')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale writeChain links after dispose', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never forwards a queued reply once the PTY state is disposed', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-stale')
|
||||
|
||||
// Queue a forward-flagged chain link, then dispose before it runs.
|
||||
runtime.onPtyData('pty-stale', DA1, Date.now())
|
||||
runtime.onPtyExit('pty-stale', 0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never injects a stale reply into a successor PTY reusing the session id', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-reuse')
|
||||
|
||||
// Daemon respawns reuse session ids: dispose with the flagged link still
|
||||
// queued, then re-create the same id before the link runs.
|
||||
runtime.onPtyData('pty-reuse', DA1, Date.now())
|
||||
runtime.onPtyExit('pty-reuse', 0)
|
||||
runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now())
|
||||
await settle(runtime, 'pty-reuse')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConPTY DA1 override', () => {
|
||||
it('retrofits the override when the spawn mark lands after data created the emulator', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-win-late')
|
||||
|
||||
// Daemon warm-reattach flush: stream data creates the emulator before
|
||||
// the awaited spawn response marks the PTY native-Windows.
|
||||
runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now())
|
||||
markNativeWindowsConptyPty('pty-win-late')
|
||||
|
||||
runtime.onPtyData('pty-win-late', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win-late')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('keeps the override single-reply when installed at creation and marked again', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win-idem')
|
||||
markHiddenRendererPty('pty-win-idem')
|
||||
|
||||
runtime.onPtyData('pty-win-idem', 'boot output', Date.now())
|
||||
// A duplicate mark (e.g. respawn against a live emulator) must not stack
|
||||
// a second handler that double-replies.
|
||||
markNativeWindowsConptyPty('pty-win-idem')
|
||||
|
||||
runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win-idem')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win')
|
||||
markHiddenRendererPty('pty-win')
|
||||
|
||||
runtime.onPtyData('pty-win', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('lets non-primary device-attribute queries fall through to the core', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win2')
|
||||
markHiddenRendererPty('pty-win2')
|
||||
|
||||
runtime.onPtyData('pty-win2', '\x1b[>c', Date.now())
|
||||
await settle(runtime, 'pty-win2')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c'])
|
||||
})
|
||||
|
||||
it('keeps the override silent for delivered chunks', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win3')
|
||||
|
||||
runtime.onPtyData('pty-win3', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win3')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessEmulator forwarding window', () => {
|
||||
it('forwards replies only for writes flagged forwardQueryReplies', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
try {
|
||||
await emulator.write('\x1b[c')
|
||||
expect(onQueryReply).not.toHaveBeenCalled()
|
||||
|
||||
await emulator.write('\x1b[c', { forwardQueryReplies: true })
|
||||
expect(onQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c')
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
// Force the async write path (xterm deprecates writeSync; the fallback
|
||||
// must stay structurally safe without writeChain serialization).
|
||||
const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } }
|
||||
internals.terminal._core.writeSync = undefined
|
||||
try {
|
||||
// Enqueue an unflagged seed carrying a query, then a flagged live
|
||||
// chunk, WITHOUT awaiting between them: both sit in xterm's write
|
||||
// queue together. The seed parse must not see an open window.
|
||||
const seed = emulator.write('seeded\x1b[c')
|
||||
const live = emulator.write('\x1b[5n', { forwardQueryReplies: true })
|
||||
await Promise.all([seed, live])
|
||||
|
||||
expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n'])
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the ConPTY override inside the forwarding window', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
emulator.installConptyPrimaryDeviceAttributesOverride()
|
||||
try {
|
||||
// Unflagged (replayed/seeded) DA1 must answer no one even with the
|
||||
// override installed.
|
||||
await emulator.write('\x1b[c')
|
||||
expect(onQueryReply).not.toHaveBeenCalled()
|
||||
|
||||
await emulator.write('\x1b[c', { forwardQueryReplies: true })
|
||||
expect(onQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c')
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('view-attribute bridge replies (after renderer push)', () => {
|
||||
// Reply bytes pinned to the renderer xterm's format: OSC replies use the
|
||||
// queried ident, 16-bit doubled-byte channels, and ST termination
|
||||
// (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with
|
||||
// the contour 997 report, same bytes as mode2031SequenceFor.
|
||||
it.each([
|
||||
['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']],
|
||||
['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']],
|
||||
['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']],
|
||||
['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']],
|
||||
['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']],
|
||||
[
|
||||
'OSC 4 multiple slots in one sequence',
|
||||
'\x1b]4;1;?;196;?\x07',
|
||||
['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']
|
||||
],
|
||||
[
|
||||
'OSC 10 stacked params report foreground then background',
|
||||
'\x1b]10;?;?\x07',
|
||||
['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']
|
||||
],
|
||||
['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']],
|
||||
['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']],
|
||||
['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']]
|
||||
])('%s', async (_label, chunk, expectedReplies) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-view')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-view', chunk, Date.now())
|
||||
await settle(runtime, 'pty-view')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
|
||||
})
|
||||
|
||||
it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-dark')
|
||||
// Supported divergence: light app mode with terminalUseSeparateLightTheme
|
||||
// off renders a dark terminal theme. A visible xterm answers ?996n from
|
||||
// bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so
|
||||
// the hidden reply must say dark here too.
|
||||
setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' }))
|
||||
|
||||
runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-dark')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n'])
|
||||
})
|
||||
|
||||
it('answers ?996n light for a light palette regardless of the pushed app mode', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-light')
|
||||
setTerminalViewAttributes(
|
||||
viewAttributes({
|
||||
foreground: [0x33, 0x33, 0x33],
|
||||
background: [0xfa, 0xfa, 0xfa],
|
||||
colorSchemeMode: 'dark'
|
||||
})
|
||||
)
|
||||
|
||||
runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-light')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
|
||||
})
|
||||
|
||||
it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => {
|
||||
// _reportColorScheme reads the CURRENT theme-service colors, which include
|
||||
// OSC 10/11 SET mutations — the per-PTY overlays layer the same way.
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-set')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-set')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
|
||||
})
|
||||
|
||||
it('stays silent before the first push, then answers the same query after it', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-first')
|
||||
|
||||
runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-first')
|
||||
// No fabricated defaults: silence is the documented hidden status quo.
|
||||
expect(replies).toEqual([])
|
||||
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-first')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it('retrofits cursor options onto already-live emulators when the push lands late', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-late')
|
||||
|
||||
// Emulator exists before any push: core default DECSCUSR is steady block.
|
||||
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
|
||||
await settle(runtime, 'pty-late')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\'])
|
||||
|
||||
setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false }))
|
||||
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
|
||||
await settle(runtime, 'pty-late')
|
||||
expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-PTY OSC color SET layering', () => {
|
||||
it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-a')
|
||||
markHiddenRendererPty('pty-b')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now())
|
||||
runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-a')
|
||||
await settle(runtime, 'pty-b')
|
||||
|
||||
expect(replies).toEqual([
|
||||
{ ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' },
|
||||
{ ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' }
|
||||
])
|
||||
})
|
||||
|
||||
it('restores a single indexed color via OSC 104;<idx>', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-104')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-104')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\'])
|
||||
})
|
||||
|
||||
it('restores the whole indexed table via bare OSC 104', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-104all')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-104all',
|
||||
'\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07',
|
||||
Date.now()
|
||||
)
|
||||
await settle(runtime, 'pty-104all')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual([
|
||||
'\x1b]4;1;rgb:cccc/0000/0000\x1b\\',
|
||||
'\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'
|
||||
])
|
||||
})
|
||||
|
||||
it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-special')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-special',
|
||||
'\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' +
|
||||
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' +
|
||||
'\x1b]110\x07\x1b]111\x07\x1b]112\x07' +
|
||||
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07',
|
||||
Date.now()
|
||||
)
|
||||
await settle(runtime, 'pty-special')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual([
|
||||
'\x1b]10;rgb:0101/0202/0303\x1b\\',
|
||||
'\x1b]11;rgb:ffff/ffff/ffff\x1b\\',
|
||||
'\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\',
|
||||
'\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\',
|
||||
'\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\',
|
||||
'\x1b]12;rgb:ffff/9999/0000\x1b\\'
|
||||
])
|
||||
})
|
||||
|
||||
it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => {
|
||||
// Cold-restore scrollback replayed into a visible renderer xterm re-applies
|
||||
// OSC SETs to its theme service; the model mirrors that state — but the
|
||||
// replay guard still keeps the seed from ANSWERING anything.
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-seedset')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07')
|
||||
await settle(runtime, 'pty-seedset')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-seedset')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\'])
|
||||
})
|
||||
|
||||
it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-idem')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now())
|
||||
await settle(runtime, 'pty-idem')
|
||||
|
||||
// A second window / renderer reload / macOS re-activation re-pushes
|
||||
// byte-identical attributes (its publisher dedupe is per-process). That is
|
||||
// not a theme apply, so the OSC SET overlay must survive.
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-idem')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\'])
|
||||
})
|
||||
|
||||
it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-clear')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now())
|
||||
await settle(runtime, 'pty-clear')
|
||||
|
||||
// A theme apply overwrites OSC-SET-mutated colors on visible panes too
|
||||
// (ThemeService._setTheme), so the model mirrors that on every CHANGED
|
||||
// push (identical re-pushes are filtered — see the test above).
|
||||
setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] }))
|
||||
runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-clear')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('view-attribute replay guard and suppression', () => {
|
||||
it('never answers view-attribute queries embedded in a seeded snapshot', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vseed')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n')
|
||||
await settle(runtime, 'pty-vseed')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vseed')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => {
|
||||
const { runtime, replies } = createResponderRuntime({
|
||||
rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 }
|
||||
})
|
||||
markHiddenRendererPty('pty-vhyd')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vhyd', 'live output', Date.now())
|
||||
await settle(runtime, 'pty-vhyd')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-vvis')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vint')
|
||||
setRendererPtyDeliveryInterest('pty-vint', true)
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vint')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('yields view-attribute replies while a remote view subscriber is attached', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vrem')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem')
|
||||
|
||||
runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vrem')
|
||||
expect(replies).toEqual([])
|
||||
|
||||
release()
|
||||
runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-vrem')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
|
||||
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
|
||||
[
|
||||
'terminalMainSideEffectAuthority',
|
||||
() => (settingsState.terminalMainSideEffectAuthority = false)
|
||||
]
|
||||
])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => {
|
||||
flip()
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-vkill')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-vkill')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
|
||||
* bridge): main-side cache of the renderer's `pty:terminalViewAttributes`
|
||||
* push. One app-global snapshot, not per-PTY — per-pane font zoom never
|
||||
* affects these attributes and the color/cursor settings are global.
|
||||
*
|
||||
* Null until the first push, and the responder answers NO view-attribute
|
||||
* query while null (silent-until-first-push): a fabricated default would
|
||||
* resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC
|
||||
* hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip.
|
||||
*/
|
||||
import {
|
||||
terminalViewAttributesEqual,
|
||||
type TerminalViewAttributes
|
||||
} from '../../shared/terminal-view-attributes'
|
||||
|
||||
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives
|
||||
// the push, the runtime emulators consult it at reply time via the getter.
|
||||
let currentAttributes: TerminalViewAttributes | null = null
|
||||
|
||||
// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push
|
||||
// must also reach already-live emulators — cursor options under the replay
|
||||
// guard, plus the per-PTY override reset a theme apply implies.
|
||||
type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void
|
||||
const pushAppliers = new Set<TerminalViewAttributesApplier>()
|
||||
|
||||
export function registerTerminalViewAttributesApplier(
|
||||
applier: TerminalViewAttributesApplier
|
||||
): void {
|
||||
pushAppliers.add(applier)
|
||||
}
|
||||
|
||||
/** Called from the pty:terminalViewAttributes IPC handler with a validated
|
||||
* payload. Last push wins (replies always use the freshest snapshot). */
|
||||
export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void {
|
||||
// Why idempotent: the renderer publisher's dedupe is per-process, so a
|
||||
// fresh renderer (second window, reload, macOS re-activation) re-pushes
|
||||
// identical attributes. That is not a theme apply — fanning out would wipe
|
||||
// every PTY's OSC SET overlay while visible panes keep theirs.
|
||||
if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) {
|
||||
return
|
||||
}
|
||||
currentAttributes = attributes
|
||||
for (const applier of pushAppliers) {
|
||||
applier(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTerminalViewAttributes(): TerminalViewAttributes | null {
|
||||
return currentAttributes
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalViewAttributesForTest(): void {
|
||||
currentAttributes = null
|
||||
pushAppliers.clear()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user