Merge remote-tracking branch 'origin/main' into brennanb2025/i18n-catalog-decoupling

This commit is contained in:
Brennan Benson
2026-07-29 17:15:54 -07:00
361 changed files with 45692 additions and 3842 deletions
@@ -6,6 +6,10 @@ inputs:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
default: none
node-version:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
runs:
using: composite
@@ -17,11 +21,19 @@ runs:
run_install: false
- name: Setup Node.js
if: inputs.node-version == ''
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Setup requested Node.js
if: inputs.node-version != ''
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
- name: Validate native runtime
shell: bash
env:
+5 -2
View File
@@ -184,12 +184,14 @@ jobs:
src/shared/posix-command-path-lookup.test.ts
test:
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shard_total: [16]
steps:
- name: Checkout
@@ -200,6 +202,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
@@ -213,7 +216,7 @@ jobs:
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
package:
name: package
+99
View File
@@ -0,0 +1,99 @@
name: Terminal IME E2E
on:
pull_request:
paths:
- '.github/workflows/terminal-ime-e2e.yml'
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
- 'tests/e2e/terminal-ime-*.ts'
workflow_dispatch:
schedule:
- cron: '30 9 * * *'
permissions:
contents: read
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install native build and IME tools
run: >-
sudo apt-get update &&
sudo apt-get install -y
build-essential
dbus-x11
dconf-gsettings-backend
ibus
ibus-hangul
libglib2.0-bin
python3
xdotool
xfwm4
xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Use external node-gyp to avoid pnpm bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run deterministic terminal IME boundary tests
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm run test:e2e --
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload terminal IME evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
-1
View File
@@ -128,7 +128,6 @@ jobs:
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--require-distinct-artifacts `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
---
-1
View File
@@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
-1
View File
@@ -153,7 +153,6 @@ module.exports = {
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
-20
View File
@@ -13,26 +13,6 @@
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!include "getProcessInfo.nsh"
Var pid
!macro customCheckAppRunning
${if} ${isUpdated}
; Why: an A-era packaged CLI may still hold Orca.exe while its exact native launcher waits.
nsExec::Exec `"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference='Stop'; $$app=[IO.Path]::GetFullPath($$args[0]); $$launcher=[IO.Path]::GetFullPath($$args[1]); $$comparison=[StringComparison]::OrdinalIgnoreCase; $$children=@(Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$_.ExecutablePath), $$app, $$comparison) }); $$launcherProofs=@(); foreach ($$child in $$children) { $$parent=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$child.ParentProcessId) -ErrorAction SilentlyContinue; if ($$parent -and $$parent.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$parent.ExecutablePath), $$launcher, $$comparison)) { $$launcherProofs += [pscustomobject]@{ ProcessId=[int]$$parent.ProcessId; CreationDate=[string]$$parent.CreationDate } }; Stop-Process -Id $$child.ProcessId -Force -ErrorAction SilentlyContinue }; $$launcherDeadline=[DateTime]::UtcNow.AddSeconds(30); do { $$remaining=@($$launcherProofs | Where-Object { $$proof=$$_; $$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$proof.ProcessId) -ErrorAction SilentlyContinue; $$current -and $$current.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$current.ExecutablePath), $$launcher, $$comparison) -and [String]::Equals([string]$$current.CreationDate, $$proof.CreationDate, [StringComparison]::Ordinal) }); if ($$remaining.Count -eq 0) { break }; Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $$launcherDeadline); if ($$remaining.Count -ne 0) { exit 20 }; $$lockDeadline=[DateTime]::UtcNow.AddSeconds(30); $$released=$$false; do { try { $$stream=[IO.File]::Open($$app, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $$stream.Dispose(); $$released=$$true } catch { Start-Sleep -Milliseconds 100 } } while (-not $$released -and [DateTime]::UtcNow -lt $$lockDeadline); if (-not $$released) { exit 21 } }" "$INSTDIR\Orca.exe" "$INSTDIR\resources\bin\orca.exe"`
Pop $0
${if} $0 != 0
DetailPrint "Orca update release proof failed with status $0."
SetErrorLevel 2
Quit
${endIf}
${else}
!insertmacro IS_POWERSHELL_AVAILABLE
!insertmacro _CHECK_APP_RUNNING
${endIf}
!macroend
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -166,11 +166,9 @@ describe('electron-builder config', () => {
)
})
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
)
})
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across sixteen runners', () => {
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
})
it('restores Electron downloads before preparing the package runtime', () => {
@@ -0,0 +1,306 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
if (result.status === 0) {
return
}
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
}
async function runInsideSession(evidenceDir) {
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
playwrightPid: null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let ibusProcess
let windowManagerProcess
let testExitCode = 1
try {
configureHangulEngine()
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
})
if (!windowManagerProcess.pid) {
throw new Error('xfwm4 did not return a PID')
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
ibusProcess = spawn(
'ibus-daemon',
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
{
detached: true,
env: process.env,
stdio: ['ignore', ibusLogFd, ibusLogFd]
}
)
if (!ibusProcess.pid) {
throw new Error('ibus-daemon did not return a PID')
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (ibusProcess?.pid) {
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(ibusLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.ibusGroupAfterCleanup.length > 0) {
throw new Error(
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionProcess = spawn(
'xvfb-run',
[
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
],
{
cwd: projectDir,
detached: true,
env: {
...process.env,
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir,
XMODIFIERS: '@im=ibus'
},
stdio: 'inherit'
}
)
if (!sessionProcess.pid) {
throw new Error('xvfb-run did not return a PID')
}
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
it('runs for xterm patch and terminal IME regression changes', () => {
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
'src/renderer/src/components/terminal-pane/pty-connection.ts',
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
'src/renderer/src/components/terminal-pane/terminal-ime-*',
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/terminal-ime-*.ts'
])
)
})
it('installs native IBus Hangul and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
expect(installRun).toContain('dbus-x11')
expect(installRun).toContain('dconf-gsettings-backend')
expect(installRun).toContain('libglib2.0-bin')
})
it('runs deterministic boundaries before the real IBus suite', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const deterministicIndex = runs.findIndex((run) =>
run.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
})
it('keeps IBus lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
'utf8'
)
expect(runner).toContain(
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
expect(runner).not.toContain('pkill')
})
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
'utf8'
)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
})
})
+2
View File
@@ -15,6 +15,8 @@ export default defineConfig({
},
test: {
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
File diff suppressed because it is too large Load Diff
@@ -417,28 +417,10 @@ status. The command name comes only from the validated inherited
than rendered. The launcher propagates status 75. No long wait starts on this
path.
The candidate installer owns the backward edge: after the desktop has shut
down, it may terminate only remaining processes whose resolved executable path
is the exact installed Orca executable before replacement. This releases an
already-blocked A CLI that cannot know the new marker. Its accepted question
remains durable; retrying the exact old ask under B reconstructs the same
pending question and prints the resume command. If launcher restart
supervision can be proved, it may transparently reconnect instead, but
seamless install cannot depend on that optimization.
After terminating the exact installed child, the installer waits boundedly for
its exact descendant native launcher to exit and for the installed executable
lock to clear. Replacement fails explicitly if either release cannot be
proved; it never proceeds from a broad process-name match or an assumed delay.
A non-skipped packaged Windows A-to-B test uses the real launcher and updater,
cuts a blocked ask, verifies post-commit output flush and status 75 propagation
on native and packaged WSL marker-aware paths, including an executable
`orca-ide` WSL resume command. It separately proves the candidate installer
terminates an exact-path marker-unaware A child and observes the exact launcher
exit plus lock release. The test fails if either release is unknown. It then
installs B, resumes the same question, and records one question/reply. This
fallback is product behavior, not test-only guidance.
This compatibility path makes the accepted question recoverable without
claiming Windows updater or uninstaller process ownership. Generic executable
drain, replacement, and uninstall behavior remains separate updater
reliability work and requires its own Windows artifact proof.
## Rendering and authoritative guidance
@@ -510,7 +492,7 @@ Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
coordinator focus; and remains inert after switching away and back. This
candidate/candidate fixture covers the renderer regression deterministically
but does not replace the distinct installed-A/candidate-B packaged cutover
journey below.
journey below. It does not claim packaged Windows updater behavior.
Use distinct installed A and B artifacts plus an external append-only fake
agent spawn ledger:
@@ -558,11 +540,9 @@ Run separate remote journeys:
- a git-independent folder workspace parameter through the same adoption
journey.
The Windows test is a non-skipped PR/release gate. It installs distinct A and
candidate-B packages, drives the real packaged updater while a packaged
`orca.exe` ask is blocked, proves the launcher and child release, verifies A
and B hashes/versions differ, resumes the same question on B, and records
exactly one question and reply.
Packaged Windows updater continuity is outside this migration gate. A separate
updater change must prove candidate-built A/B install, executable release,
resume, and normal uninstall behavior on Windows before making that guarantee.
## Implementation slices
+2 -3
View File
@@ -208,9 +208,8 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
@@ -2530,9 +2530,12 @@ private enum KeyMap {
}
private final class AgentRuntime: NSObject, NSApplicationDelegate {
private static let unclaimedSessionDeadline: TimeInterval = 30
private let socketPath: String
private let token: String?
private var listener: SocketListener?
private var unclaimedSessionTimeout: DispatchWorkItem?
init(socketPath: String, token: String?) {
self.socketPath = socketPath
@@ -2541,9 +2544,31 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
do {
let listener = try SocketListener(socketPath: socketPath, token: token)
let timeout = DispatchWorkItem {
fputs("computer-use agent received no authenticated session before its deadline\n", stderr)
NSApp.terminate(nil)
}
unclaimedSessionTimeout = timeout
let listener = try SocketListener(
socketPath: socketPath,
token: token,
onSessionClaimed: {
DispatchQueue.main.async {
timeout.cancel()
}
},
onSessionClosed: {
DispatchQueue.main.async {
NSApp.terminate(nil)
}
}
)
self.listener = listener
listener.start()
DispatchQueue.main.asyncAfter(
deadline: .now() + Self.unclaimedSessionDeadline,
execute: timeout
)
} catch {
fputs("failed to start computer-use socket: \(error)\n", stderr)
NSApp.terminate(nil)
@@ -2551,6 +2576,8 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
unclaimedSessionTimeout?.cancel()
unclaimedSessionTimeout = nil
listener?.stop()
}
}
@@ -3456,14 +3483,25 @@ private final class ButtonTarget: NSObject {
private final class SocketListener: @unchecked Sendable {
private let socketPath: String
private let token: String?
private let onSessionClaimed: () -> Void
private let onSessionClosed: () -> Void
private let provider = Provider()
private let providerLock = NSLock()
private let sessionLock = NSLock()
private var sessionOwnership = AgentSessionOwnership()
private var socketFd: Int32 = -1
private var isStopped = false
init(socketPath: String, token: String?) throws {
init(
socketPath: String,
token: String?,
onSessionClaimed: @escaping () -> Void,
onSessionClosed: @escaping () -> Void
) throws {
self.socketPath = socketPath
self.token = token
self.onSessionClaimed = onSessionClaimed
self.onSessionClosed = onSessionClosed
try bindSocket()
}
@@ -3545,15 +3583,39 @@ private final class SocketListener: @unchecked Sendable {
}
private func handleConnection(_ fd: Int32) {
defer { close(fd) }
defer {
sessionLock.lock()
let shouldTerminate = sessionOwnership.disconnect(fd)
sessionLock.unlock()
close(fd)
if shouldTerminate {
onSessionClosed()
}
}
let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true
let decoder = JSONDecoder()
var registrationComplete = false
while let line = readLine(from: fd) {
guard let data = line.data(using: .utf8),
let request = try? decoder.decode(Request.self, from: data)
else {
continue
}
if !registrationComplete {
sessionLock.lock()
let registration = sessionOwnership.registerConnection(
fd,
authenticated: isAuthenticated(
request: request,
authorizedPeer: authorizedPeer
)
)
sessionLock.unlock()
registrationComplete = registration != .rejected
if registration == .claimed {
onSessionClaimed()
}
}
let response = handleRequest(
provider: provider,
lock: providerLock,
@@ -3564,6 +3626,12 @@ private final class SocketListener: @unchecked Sendable {
writeJSON(response, to: fd)
}
}
private func isAuthenticated(request: Request, authorizedPeer: Bool) -> Bool {
guard authorizedPeer else { return false }
guard let token else { return true }
return request.token == token
}
}
private func existingPathMode(_ path: String) -> mode_t? {
@@ -3696,7 +3764,7 @@ private func handleRequest(
if let expectedToken, request.token != expectedToken {
return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "invalid computer-use agent token"]]
}
if expectedToken != nil && !authorizedPeer {
if !authorizedPeer {
return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "computer-use agent peer is not authorized"]]
}
if request.method == "terminate" {
@@ -0,0 +1,35 @@
public enum AgentSessionRegistration: Sendable, Equatable {
case rejected
case sessionReleased
case joined
case claimed
}
public struct AgentSessionOwnership: Sendable {
private var authenticatedConnections: Set<Int32> = []
private var wasClaimed = false
private var wasReleased = false
public init() {}
public mutating func registerConnection(
_ connection: Int32,
authenticated: Bool
) -> AgentSessionRegistration {
guard authenticated else { return .rejected }
guard !wasReleased else { return .sessionReleased }
let inserted = authenticatedConnections.insert(connection).inserted
guard inserted, !wasClaimed else { return .joined }
wasClaimed = true
return .claimed
}
public mutating func disconnect(_ connection: Int32) -> Bool {
guard authenticatedConnections.remove(connection) != nil else { return false }
let released = wasClaimed && authenticatedConnections.isEmpty
if released {
wasReleased = true
}
return released
}
}
@@ -0,0 +1,50 @@
import OrcaComputerUseMacOSCore
import XCTest
final class AgentSessionOwnershipTests: XCTestCase {
func testUnclaimedDisconnectDoesNotTerminateAgent() {
var ownership = AgentSessionOwnership()
XCTAssertFalse(ownership.disconnect(12))
}
func testUnauthenticatedConnectionCannotClaimOrRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: false), .rejected)
XCTAssertFalse(ownership.disconnect(12))
}
func testLastAuthenticatedDisconnectTerminatesAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(12))
}
func testAgentWaitsForEveryAuthenticatedConnectionToClose() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(13, authenticated: true), .joined)
XCTAssertFalse(ownership.disconnect(12))
XCTAssertTrue(ownership.disconnect(13))
}
func testDuplicateRegistrationDoesNotRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .joined)
XCTAssertTrue(ownership.disconnect(12))
}
func testReleasedSessionCannotBeReclaimed() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(12))
XCTAssertEqual(ownership.registerConnection(13, authenticated: true), .sessionReleased)
XCTAssertFalse(ownership.disconnect(13))
}
}
+1
View File
@@ -100,6 +100,7 @@
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
+24 -24
View File
@@ -19,7 +19,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -46,7 +46,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -206,25 +206,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9579,39 +9579,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,559 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSubagentSnapshot } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../shared/ssh-pty-id'
import { AgentHookServer } from './server'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
resolveAgentWorkspaceExecutionHostId,
sweepRestoredSubagentsWithoutLiveAgent
} from './restored-subagent-liveness-sweep'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const PTY = 'wt-1__pty-1'
const WORKING_CHILD: AgentSubagentSnapshot = {
id: 'areview-loop-c237a4c577493352',
state: 'working',
startedAt: 1_000
}
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-restored-subagent-'))
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(dir, { recursive: true, force: true })
})
/** Persist a pane whose lead has finished but whose roster still holds a working
* child, then restart into a fresh server — the shape a machine sleep leaves
* behind when the child's SubagentStop is lost while Orca is down. */
async function restartWithInFlightSubagent(options?: {
connectionId?: string
state?: 'working' | 'waiting'
subagents?: AgentSubagentSnapshot[]
additionalPaneKey?: string
}): Promise<AgentHookServer> {
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath: dir })
first.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: options?.connectionId ?? null,
payload: {
state: options?.state ?? 'working',
prompt: 'review the PR',
agentType: 'claude',
subagents: options?.subagents ?? [WORKING_CHILD]
}
})
if (options?.additionalPaneKey) {
first.ingestTerminalStatus({
paneKey: options.additionalPaneKey,
tabId: 'tab-2',
worktreeId: 'wt-1',
connectionId: options.connectionId ?? null,
payload: {
state: options.state ?? 'working',
prompt: 'review the PR',
agentType: 'claude',
subagents: options.subagents ?? [WORKING_CHILD]
}
})
}
first.flushStatusPersistSync()
first.stop()
const restarted = new AgentHookServer()
await restarted.start({ env: 'production', userDataPath: dir })
return restarted
}
function sweepWith(
server: AgentHookServer,
overrides: {
probeLiveLocalPty?: (ptyId: string) => boolean | null | Promise<boolean | null>
executionHostId?: string | null
boundPtyIdByPaneKey?: Record<string, string>
persistedPtyIdByPaneKey?: Record<string, string>
} = {}
): Promise<number> {
return sweepRestoredSubagentsWithoutLiveAgent({
probeLiveLocalPty: async (ptyId) =>
overrides.probeLiveLocalPty ? await overrides.probeLiveLocalPty(ptyId) : false,
isLocalExecutionHost: () =>
isLocalExecutionHost(
overrides.executionHostId === undefined ? 'local' : overrides.executionHostId
),
getBoundPtyIdForPaneKey: (paneKey) => overrides.boundPtyIdByPaneKey?.[paneKey],
getPersistedPtyIdForPaneKey: (paneKey) => overrides.persistedPtyIdByPaneKey?.[paneKey],
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
server.reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalHost,
isLocalPaneAgentLive,
isLocalPaneLivenessEvidenceCurrent
)
})
}
function paneStatus(
server: AgentHookServer,
paneKey = PANE
): { state: string; subagents?: AgentSubagentSnapshot[] } {
const entry = server.getStatusSnapshotForPane(paneKey)[0]
return { state: entry?.state ?? 'missing', subagents: entry?.subagents }
}
describe('restored subagent liveness sweep', () => {
it('reaps the phantom seed so a slept-through pane reaches done', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
const previous = server.getStatusSnapshotForPane(PANE)[0]
const previousReceivedAt = previous?.receivedAt ?? 0
const reconciledAt = previousReceivedAt + 1
const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt)
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
expect(paneStatus(server)).toEqual({ state: 'done', subagents: undefined })
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
receivedAt: reconciledAt,
stateStartedAt: reconciledAt
})
now.mockRestore()
} finally {
server.stop()
}
})
it('keeps a seed whose pane still has a live local PTY', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: (ptyId) => ptyId === PTY,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
} finally {
server.stop()
}
})
it('preserves timing when reaping rows does not change the lead state', async () => {
const server = await restartWithInFlightSubagent({ state: 'waiting' })
try {
const previous = server.getStatusSnapshotForPane(PANE)[0]
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
state: 'waiting',
subagents: undefined,
receivedAt: previous?.receivedAt,
stateStartedAt: previous?.stateStartedAt
})
} finally {
server.stop()
}
})
it('uses targeted liveness for a PTY bound in this runtime', async () => {
const server = await restartWithInFlightSubagent()
try {
const probeLiveLocalPty = vi.fn((ptyId: string) => ptyId === PTY)
expect(
await sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('probes only restored rosters and deduplicates a shared exact PTY', async () => {
const otherPane = makePaneKey('tab-2', LEAF)
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: PTY }
})
).toBe(2)
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
} finally {
server.stop()
}
})
it('does not probe a hydrated Claude pane without restored rows', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('never reaps an SSH-launched pane, whose agent cannot appear in a local scan', async () => {
const sshPtyId = toAppSshPtyId('conn-1', PTY)
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
executionHostId: 'ssh:conn-1',
persistedPtyIdByPaneKey: { [PANE]: sshPtyId }
})
).toBe(0)
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
} finally {
server.stop()
}
})
it('never reaps a relay-owned pane even with no local PTY at all', async () => {
const server = await restartWithInFlightSubagent({ connectionId: 'conn-1' })
try {
expect(await sweepWith(server)).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('never reaps a runtime-hosted pane from the desktop local provider', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
executionHostId: 'runtime:ephemeral-vm-1',
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: 'remote:ephemeral-vm-1@@pty-1' }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('does nothing when targeted PTY liveness is unknown', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => null,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('does nothing without an exact pane PTY binding', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(await sweepWith(server, { probeLiveLocalPty })).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('skips panes that have reported to this runtime', async () => {
const server = await restartWithInFlightSubagent()
try {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'now reviewing round two',
agentType: 'claude',
subagents: [WORKING_CHILD]
}
})
expect(await sweepWith(server)).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that reports while its liveness probe is pending', async () => {
const server = await restartWithInFlightSubagent()
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledOnce())
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'confirmed during probe',
agentType: 'claude',
subagents: [WORKING_CHILD]
}
})
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that binds a newer PTY while the old probe is pending', async () => {
const server = await restartWithInFlightSubagent()
const boundPtyIdByPaneKey = { [PANE]: PTY }
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, { probeLiveLocalPty, boundPtyIdByPaneKey })
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = 'wt-1__pty-new'
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that rebinds its persisted PTY while the probe is pending', async () => {
const server = await restartWithInFlightSubagent()
const boundPtyIdByPaneKey: Record<string, string> = {}
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = PTY
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a same-id rebind that lands after its probe but before the batch settles', async () => {
const otherPane = makePaneKey('tab-2', LEAF)
const otherPty = 'wt-1__pty-2'
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
const boundPtyIdByPaneKey: Record<string, string> = {}
let resolveOtherProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn((ptyId: string) =>
ptyId === PTY
? Promise.resolve(false)
: new Promise<boolean | null>((resolve) => {
resolveOtherProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: otherPty }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledTimes(2))
boundPtyIdByPaneKey[PANE] = PTY
resolveOtherProbe(false)
expect(await sweep).toBe(1)
expect(paneStatus(server).state).toBe('working')
expect(paneStatus(server, otherPane).state).toBe('done')
} finally {
server.stop()
}
})
it('leaves a pane whose lead is genuinely mid-turn at working', async () => {
const server = await restartWithInFlightSubagent()
try {
// Why: the lead's own tool event proves the process is alive; only the
// child-gated 'working' may be re-derived, so the state must survive.
server.ingestTerminalStatus({
paneKey: makePaneKey('tab-2', LEAF),
tabId: 'tab-2',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'still going', agentType: 'claude' }
})
await sweepWith(server)
expect(paneStatus(server, makePaneKey('tab-2', LEAF)).state).toBe('working')
} finally {
server.stop()
}
})
it('reports zero and leaves state alone when targeted liveness throws', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => {
throw new Error('daemon unreachable')
})
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
})
describe('indexPersistedPaneKeyPtyIds', () => {
it('maps layout leaves to pane keys and ignores empty bindings', () => {
expect(
indexPersistedPaneKeyPtyIds({
'tab-1': { ptyIdsByLeafId: { [LEAF]: PTY, 'leaf-empty': '' } },
'tab-2': undefined,
'tab-3': {}
})
).toEqual(new Map([[PANE, PTY]]))
})
})
describe('resolveAgentWorkspaceExecutionHostId', () => {
const localRepo = {
id: 'local-repo',
connectionId: null,
executionHostId: 'local' as const
}
const runtimeRepo = {
id: 'runtime-repo',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1' as const
}
const futureHostRepo = {
id: 'future-repo',
connectionId: null,
executionHostId: 'container:future-host'
}
const deps = {
getRepo: (repoId: string) =>
[localRepo, runtimeRepo, futureHostRepo].find((candidate) => candidate.id === repoId),
getWorktreeMeta: (worktreeId: string) => {
if (worktreeId === 'local-repo::/runtime-worktree') {
return { hostId: 'runtime:worktree-owner' }
}
if (worktreeId === 'runtime-repo::/local-worktree') {
return { hostId: 'local' }
}
if (worktreeId === 'local-repo::/future-worktree') {
return { hostId: 'container:future-host' }
}
return undefined
},
getFolderWorkspace: (id: string) =>
id === 'folder-runtime' ? { projectGroupId: 'group-runtime', connectionId: null } : undefined,
getProjectGroups: () => [
{
id: 'group-runtime',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1'
}
]
}
it('positively identifies local ownership and rejects runtime hosts', () => {
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/repo', deps)).toBe('local')
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/repo', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('folder:folder-runtime', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/runtime-worktree', deps)).toBe(
'runtime:worktree-owner'
)
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/local-worktree', deps)).toBe(
'local'
)
expect(resolveAgentWorkspaceExecutionHostId('future-repo::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/future-worktree', deps)).toBeNull()
expect(isLocalExecutionHost('local')).toBe(true)
expect(isLocalExecutionHost('runtime:ephemeral-vm-1')).toBe(false)
})
it('treats missing workspace provenance as unknown', () => {
expect(resolveAgentWorkspaceExecutionHostId('missing::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId(undefined, deps)).toBeNull()
expect(isLocalExecutionHost(null)).toBe(false)
})
})
@@ -0,0 +1,135 @@
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { FolderWorkspace, ProjectGroup } from '../../shared/types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
export type RestoredSubagentLivenessSweepDeps = {
/** Targeted provider liveness, or null when the provider cannot prove either state. */
probeLiveLocalPty: (ptyId: string) => Promise<boolean | null>
isLocalExecutionHost: (worktreeId: string | undefined) => boolean
/** PTY bound to this pane in the current session, if it has one. */
getBoundPtyIdForPaneKey: (paneKey: string) => string | undefined
/** PTY this pane was bound to when the session was last persisted; covers panes
* whose surviving daemon session has not been reattached yet. */
getPersistedPtyIdForPaneKey: (paneKey: string) => string | undefined
reap: (
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
) => Promise<number>
}
/** Drop restored rows only when the owning host is local and its provider proves
* the exact PTY absent. */
export async function sweepRestoredSubagentsWithoutLiveAgent(
deps: RestoredSubagentLivenessSweepDeps
): Promise<number> {
const probesByPtyId = new Map<string, Promise<boolean | null>>()
const boundPtyIdAtProbeByPaneKey = new Map<string, string | undefined>()
return await deps.reap(
(worktreeId) => deps.isLocalExecutionHost(worktreeId),
async (paneKey) => {
const boundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
boundPtyIdAtProbeByPaneKey.set(paneKey, boundPtyId)
const ptyId = boundPtyId ?? deps.getPersistedPtyIdForPaneKey(paneKey)
if (!ptyId) {
return true
}
try {
let probe = probesByPtyId.get(ptyId)
if (!probe) {
probe = deps.probeLiveLocalPty(ptyId)
probesByPtyId.set(ptyId, probe)
}
const live = await probe
const currentBoundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
// Why: cold restore can rebind the persisted id while its absence probe is in flight.
return currentBoundPtyId !== boundPtyId || live !== false
} catch {
return true
}
},
(paneKey) =>
!boundPtyIdAtProbeByPaneKey.has(paneKey) ||
deps.getBoundPtyIdForPaneKey(paneKey) === boundPtyIdAtProbeByPaneKey.get(paneKey)
)
}
/** Index the persisted terminal layouts as `paneKey -> ptyId`. Layout leaves are
* the only persisted binding that carries a stable pane key, so tab-level PTY ids
* (legacy numeric panes) are deliberately skipped. */
export function indexPersistedPaneKeyPtyIds(
layoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string> } | undefined>
): Map<string, string> {
const byPaneKey = new Map<string, string>()
for (const [tabId, layout] of Object.entries(layoutsByTabId)) {
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (ptyId) {
byPaneKey.set(`${tabId}:${leafId}`, ptyId)
}
}
}
return byPaneKey
}
type AgentWorkspaceExecutionHostDeps = {
getRepo: (repoId: string) => ExecutionHostOwner | null | undefined
getWorktreeMeta: (worktreeId: string) => { hostId?: string | null } | null | undefined
getFolderWorkspace: (
folderWorkspaceId: string
) => Pick<FolderWorkspace, 'projectGroupId' | 'connectionId'> | null | undefined
getProjectGroups: () => readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
}
type ExecutionHostOwner = {
connectionId?: string | null
executionHostId?: string | null
}
function resolveDeclaredExecutionHost(owner: ExecutionHostOwner): ExecutionHostId | null {
if (owner.executionHostId?.trim()) {
return parseExecutionHostId(owner.executionHostId)?.id ?? null
}
const connectionId = owner.connectionId?.trim()
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
}
/** Resolve persisted workspace ownership; unknown provenance is not local authority. */
export function resolveAgentWorkspaceExecutionHostId(
workspaceId: string | undefined,
deps: AgentWorkspaceExecutionHostDeps
): ExecutionHostId | null {
if (!workspaceId) {
return null
}
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
const workspace = deps.getFolderWorkspace(scope.folderWorkspaceId)
const group = workspace
? deps.getProjectGroups().find((candidate) => candidate.id === workspace.projectGroupId)
: undefined
if (!workspace || !group) {
return null
}
return resolveDeclaredExecutionHost({
connectionId: workspace.connectionId ?? group.connectionId,
executionHostId: group.executionHostId
})
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
const declaredWorktreeHost = deps.getWorktreeMeta(worktreeId)?.hostId?.trim()
if (declaredWorktreeHost) {
return parseExecutionHostId(declaredWorktreeHost)?.id ?? null
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
return repo ? resolveDeclaredExecutionHost(repo) : null
}
export function isLocalExecutionHost(hostId: string | null | undefined): boolean {
return parseExecutionHostId(hostId)?.kind === 'local'
}
+91
View File
@@ -25,6 +25,7 @@ import {
normalizeHookPayload,
parseFormEncodedBody,
readRequestBody,
reapRestoredClaudeSubagentsForDeadPane,
reconcileRemoteCodexState,
resolveHookSource,
preparePendingGrokResultDiscovery,
@@ -35,6 +36,11 @@ import {
type AgentHookEventPayload,
type HookListenerState
} from '../../shared/agent-hook-listener'
import {
claudeRosterHasRestoredSnapshotSubagent,
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../shared/claude-subagent-roster'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import {
CLAUDE_STATUSLINE_PATHNAME,
@@ -2171,6 +2177,91 @@ export class AgentHookServer {
}
}
/** Second reap path for restored Claude subagent rows: drop the ones whose pane
* has no live local agent process behind it any more. A PTY that dies while Orca
* is down never runs the teardown that clears pane state, so hydrate rebuilds a
* roster nothing can ever retire — the inventory reap needs the parent to emit a
* complete `background_tasks` list and an idle parent never does. The row then
* gates the pane 'working' for the rest of its life and hibernation, which
* requires 'done', can never reclaim the agent's heap.
*
* Both the execution host and relay binding must prove local ownership before
* targeted PTY liveness is consulted. Panes that reported in this runtime are
* also skipped. Returns the number of panes changed. */
async reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
): Promise<number> {
const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
const enriched = entry as EnrichedAgentHookEventPayload
if (
enriched.payload.agentType === 'claude' &&
enriched.connectionId === null &&
isLocalExecutionHost(enriched.worktreeId) &&
claudeRosterHasRestoredSnapshotSubagent(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
) &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
candidates.push({ paneKey, entry: enriched })
}
}
const liveness = await Promise.all(
candidates.map(async (candidate) => {
try {
return await isLocalPaneAgentLive(candidate.paneKey)
} catch {
return true
}
})
)
let changedPanes = 0
for (const [index, candidate] of candidates.entries()) {
const { paneKey, entry: enriched } = candidate
if (
liveness[index] ||
!isLocalPaneLivenessEvidenceCurrent(paneKey) ||
this.state.lastStatusByPaneKey.get(paneKey) !== enriched ||
this.runtimeObservedStatusPaneKeys.has(paneKey) ||
!isLocalExecutionHost(enriched.worktreeId)
) {
continue
}
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
continue
}
changedPanes += 1
const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey)
const subagents = claudeRosterToSnapshots(roster)
// Why: the pane's persisted 'working' was the child gate holding a finished
// lead open (subagent events never set lead state). With the last working row
// gone and no process left to report, 'done' is the only truthful state — and
// the one hibernation needs once this pane's agent is restored.
const state =
enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster)
? 'done'
: enriched.payload.state
const stateChanged = state !== enriched.payload.state
const reconciledAt = stateChanged
? Math.max(Date.now(), enriched.receivedAt + 1)
: enriched.receivedAt
const reconciled: EnrichedAgentHookEventPayload = {
...enriched,
receivedAt: reconciledAt,
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
payload: { ...enriched.payload, state, subagents }
}
this.state.lastStatusByPaneKey.set(paneKey, reconciled)
}
if (changedPanes > 0) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
return changedPanes
}
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
return {}
@@ -16,14 +16,20 @@ type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.f
function fakeChild(): FakeChild {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stdout: EventEmitter & {
pause: ReturnType<typeof vi.fn>
resume: ReturnType<typeof vi.fn>
}
stderr: EventEmitter
stdin: { write: ReturnType<typeof vi.fn> }
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stdout = Object.assign(new EventEmitter(), {
pause: vi.fn(),
resume: vi.fn()
})
child.stderr = new EventEmitter()
child.stdin = { write: vi.fn(() => true) }
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
child.kill = vi.fn()
return child as unknown as FakeChild
}
@@ -62,6 +68,10 @@ describe('waitForWslRelaySentinel', () => {
const transport = await promise
expect(typeof transport.write).toBe('function')
expect(typeof transport.onData).toBe('function')
transport.pauseReads?.()
transport.resumeReads?.()
expect(child.stdout.pause).toHaveBeenCalledOnce()
expect(child.stdout.resume).toHaveBeenCalledOnce()
})
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
@@ -149,4 +159,25 @@ describe('waitForWslRelaySentinel', () => {
expect(err.message).toContain('E_FAIL')
expect(err.message).not.toContain(String.fromCharCode(0))
})
it('forwards write(false), callback settlement, and drain from WSL stdin', async () => {
const child = fakeChild()
const callback = vi.fn()
const drain = vi.fn()
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
writeMock.mockImplementation((_data, onWritten) => {
onWritten(null)
return false
})
const promise = waitForWslRelaySentinel(child)
emitStdout(child, RELAY_SENTINEL)
const transport = await promise
transport.onDrain?.(drain)
expect(transport.write(Buffer.from('frame'), callback)).toBe(false)
expect(callback).toHaveBeenCalledWith({ ok: true })
child.stdin.emit('drain')
expect(drain).toHaveBeenCalledOnce()
expect(transport.supportsWriteSettlement).toBe(true)
})
})
@@ -123,12 +123,15 @@ export function waitForWslRelaySentinel(
pendingChunks.push(trailing)
}
const transport: MultiplexerTransport = {
write: (data) => {
try {
child.stdin.write(data)
} catch {
// Channel already closing — mux close handling takes over.
}
write: (data, onSettled) => {
return child.stdin.write(data, (error?: Error | null) => {
onSettled?.(error ? { ok: false, error } : { ok: true })
})
},
supportsWriteSettlement: true,
onDrain: (cb) => {
child.stdin.on('drain', cb)
return () => child.stdin.off('drain', cb)
},
onData: (cb) => {
dataCallbacks.push(cb)
@@ -143,6 +146,8 @@ export function waitForWslRelaySentinel(
}
},
onClose: (cb) => closeCallbacks.push(cb),
pauseReads: () => child.stdout.pause(),
resumeReads: () => child.stdout.resume(),
close: () => child.kill()
}
resolve(transport)
@@ -25,27 +25,4 @@ describe('packaged Windows CLI launcher asset', () => {
expect(source).toContain('child.WaitForExit();')
expect(source).toContain('return child.ExitCode;')
})
it('requires exact updater process and executable release proof', () => {
const includePath = join(process.cwd(), 'config', 'nsis', 'daemon-host-uninstall.nsh')
const source = readFileSync(includePath, 'utf8')
expect(source).toContain('!macro customCheckAppRunning')
expect(source).toContain('!include "getProcessInfo.nsh"')
expect(source).toContain('Var pid')
expect(source).toContain('[String]::Equals')
expect(source).toContain('"$INSTDIR\\Orca.exe" "$INSTDIR\\resources\\bin\\orca.exe"')
expect(source).toContain('CreationDate=[string]$$parent.CreationDate')
expect(source).toContain("'ProcessId=' + $$proof.ProcessId")
expect(source).toContain('[IO.FileShare]::None')
expect(source).toContain('$$released=$$false')
expect(source).toContain('$$released=$$true')
expect(source).not.toMatch(/(?<!\$)(?:\$\$)*\$(?:false|true)\b/)
expect(source).toContain(
' ${else}\n !insertmacro IS_POWERSHELL_AVAILABLE\n !insertmacro _CHECK_APP_RUNNING\n ${endIf}'
)
expect(source).toContain('SetErrorLevel 2')
expect(source).toContain('Quit')
expect(source).not.toContain('taskkill /IM Orca.exe')
})
})
@@ -1180,6 +1180,30 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
describe('probePtyLiveness', () => {
it('reads daemon truth before a fresh adapter has attached the session', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
const activeSessionIds = (adapter as unknown as { activeSessionIds: Set<string> })
.activeSessionIds
activeSessionIds.clear()
expect(adapter.hasPty(id)).toBe(false)
await expect(adapter.probePtyLiveness(id)).resolves.toBe(true)
await expect(adapter.probePtyLiveness('missing-session')).resolves.toBe(false)
})
it('returns unknown when the daemon cannot answer', async () => {
const client = (
adapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
vi.spyOn(client, 'request').mockRejectedValueOnce(new Error('unavailable'))
await expect(adapter.probePtyLiveness('session')).resolves.toBeNull()
})
})
describe('getBufferSnapshot', () => {
it('returns the daemon model with its absolute stream sequence', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
+12
View File
@@ -813,6 +813,18 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.activeSessionIds.has(id)
}
async probePtyLiveness(id: string): Promise<boolean | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id }
)
return result.size !== null
} catch {
return null
}
}
write(id: string, data: string): void {
this.markSessionDirty(id)
// Why recoverable and not just active: rejecting a write asks the pane to remount,
@@ -0,0 +1,20 @@
import type { IPtyProvider } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
export async function probePtyOwners(
id: string,
routed: IPtyProvider | undefined,
possibleOwners: readonly DaemonPtyAdapter[]
): Promise<boolean | null> {
if (routed) {
return routed.probePtyLiveness
? await routed.probePtyLiveness(id)
: (routed.hasPty?.(id) ?? null)
}
const results = await Promise.all(possibleOwners.map((provider) => provider.probePtyLiveness(id)))
return results.some((result) => result === true)
? true
: results.every((result) => result === false)
? false
: null
}
+20
View File
@@ -62,6 +62,7 @@ function createAdapter(
}))
),
hasPty: vi.fn((id: string) => sessions.includes(id)),
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
write: vi.fn((id: string, data: string) => {
writes.push({ id, data })
}),
@@ -470,6 +471,25 @@ describe('DaemonPtyRouter', () => {
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('probes every possible daemon owner for an unmapped session', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy', ['surviving-session'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.probePtyLiveness('surviving-session')).resolves.toBe(true)
expect(current.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session')
expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session')
})
it('does not report absence while any possible daemon owner is unavailable', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy')
vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull()
})
it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
+5
View File
@@ -10,6 +10,7 @@ import type {
} from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { probePtyOwners } from './daemon-pty-liveness-probe'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
@@ -108,6 +109,10 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.current.hasPty(id) || this.legacy.some((adapter) => adapter.hasPty(id))
}
async probePtyLiveness(id: string): Promise<boolean | null> {
return await probePtyOwners(id, this.sessionAdapters.get(id), this.allAdapters())
}
write(id: string, data: string): void {
this.adapterFor(id).write(id, data)
}
@@ -5,6 +5,7 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
type ProviderMock = IPtyProvider & {
probePtyLiveness: (id: string) => Promise<boolean | null>
inspectProcess: (id: string) => Promise<PtyProcessInspection>
emitData: (id: string, data: string, sequenceChars?: number) => void
emitReplay: (id: string, data: string) => void
@@ -31,6 +32,7 @@ function createProvider(
}),
attach: vi.fn(async () => {}),
hasPty: vi.fn((id: string) => sessions.includes(id)),
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings),
write: vi.fn(),
resize: vi.fn(),
@@ -254,6 +256,20 @@ describe('DegradedDaemonPtyProvider', () => {
expect(fallback.write).not.toHaveBeenCalled()
})
it('probes daemon owners without borrowing fallback liveness', async () => {
const current = createDaemonAdapter('current')
const legacy = createDaemonAdapter('legacy')
const fallback = createProvider('fallback', ['unknown-session'])
const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback })
vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null)
await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull()
expect(fallback.probePtyLiveness).not.toHaveBeenCalled()
vi.mocked(current.probePtyLiveness).mockResolvedValue(true)
await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true)
})
it('routes authoritative recovery snapshots to the owning daemon', async () => {
const current = createDaemonAdapter('daemon', ['daemon-session'])
const fallback = createProvider('fallback')
+13 -16
View File
@@ -11,6 +11,8 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import { findDaemonAdapter, listProviderSessionIds } from './degraded-daemon-session-routing'
import { probePtyOwners } from './daemon-pty-liveness-probe'
export class DegradedDaemonPtyProvider implements IPtyProvider {
readonly routesFreshSpawnsToLocalProvider = true
@@ -81,6 +83,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null
}
async probePtyLiveness(id: string): Promise<boolean | null> {
return await probePtyOwners(id, this.sessionProviders.get(id), this.allDaemonAdapters())
}
// Why: an unknown id cannot borrow listing authority from the fresh-spawn provider.
providesAgentSessionOwnerListings = (ptyId: string): boolean =>
(
@@ -246,11 +252,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
}
ackColdRestore(sessionId: string): void {
this.daemonAdapterFor(sessionId)?.ackColdRestore(sessionId)
findDaemonAdapter(this.sessionProviders, this.allDaemonAdapters(), sessionId)?.ackColdRestore(
sessionId
)
}
clearTombstone(sessionId: string): void {
this.daemonAdapterFor(sessionId)?.clearTombstone(sessionId)
findDaemonAdapter(this.sessionProviders, this.allDaemonAdapters(), sessionId)?.clearTombstone(
sessionId
)
}
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
@@ -289,7 +299,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
}
getCurrentDaemonSessionIds(): string[] {
return this.sessionIdsForProvider(this.current)
return listProviderSessionIds(this.sessionProviders, this.current)
}
fanoutCurrentDaemonSyntheticExits(code: number): void {
@@ -338,19 +348,6 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return null
}
private sessionIdsForProvider(provider: IPtyProvider): string[] {
return [...this.sessionProviders]
.filter(([, mappedProvider]) => mappedProvider === provider)
.map(([id]) => id)
}
private daemonAdapterFor(sessionId: string): DaemonPtyAdapter | null {
const provider = this.sessionProviders.get(sessionId)
return provider && this.allDaemonAdapters().includes(provider as DaemonPtyAdapter)
? (provider as DaemonPtyAdapter)
: null
}
private allProviders(): IPtyProvider[] {
return [this.fallback, ...this.allDaemonAdapters()]
}
@@ -0,0 +1,22 @@
import type { IPtyProvider } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
export function listProviderSessionIds(
sessionProviders: ReadonlyMap<string, IPtyProvider>,
provider: IPtyProvider
): string[] {
return [...sessionProviders]
.filter(([, mappedProvider]) => mappedProvider === provider)
.map(([id]) => id)
}
export function findDaemonAdapter(
sessionProviders: ReadonlyMap<string, IPtyProvider>,
daemonAdapters: readonly DaemonPtyAdapter[],
sessionId: string
): DaemonPtyAdapter | null {
const provider = sessionProviders.get(sessionId)
return provider && daemonAdapters.includes(provider as DaemonPtyAdapter)
? (provider as DaemonPtyAdapter)
: null
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
const TIMEOUT_MS = 45_000
const CHECK_INTERVAL_MS = 5_000
@@ -8,7 +8,7 @@ function loopWithClock(startAt = 0) {
let now = startAt
const onHangDetected = vi.fn()
const onHangResolved = vi.fn()
const loop = createHangWatchdogChildLoop({
const loop = createHangWatchdogDetectionLoop({
timeoutMs: TIMEOUT_MS,
checkIntervalMs: CHECK_INTERVAL_MS,
now: () => now,
@@ -18,7 +18,7 @@ function loopWithClock(startAt = 0) {
return { loop, onHangDetected, onHangResolved, advance: (ms: number) => (now += ms) }
}
describe('createHangWatchdogChildLoop', () => {
describe('createHangWatchdogDetectionLoop', () => {
it('does not fire while heartbeats keep arriving', () => {
const { loop, onHangDetected, advance } = loopWithClock()
for (let i = 0; i < 100; i++) {
@@ -1,4 +1,4 @@
export type HangWatchdogChildLoopConfig = {
export type HangWatchdogDetectionLoopConfig = {
timeoutMs: number
checkIntervalMs: number
now: () => number
@@ -7,14 +7,14 @@ export type HangWatchdogChildLoopConfig = {
onHangResolved: (unresponsiveMs: number) => void
}
export type HangWatchdogChildLoop = {
export type HangWatchdogDetectionLoop = {
recordHeartbeat: () => void
tick: () => void
}
export function createHangWatchdogChildLoop(
config: HangWatchdogChildLoopConfig
): HangWatchdogChildLoop {
export function createHangWatchdogDetectionLoop(
config: HangWatchdogDetectionLoopConfig
): HangWatchdogDetectionLoop {
let lastHeartbeatAt = config.now()
let lastTickAt = config.now()
let detected = false
@@ -1,17 +0,0 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
export function resolveHangWatchdogEntryPath(
appPath: string,
isPackaged: boolean,
pathExists: (candidate: string) => boolean = existsSync
): string {
// Why: ELECTRON_RUN_AS_NODE bypasses asar integration, so the packaged entry must be forked from app.asar.unpacked.
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
const adjacentBuildEntry = join(basePath, 'main-thread-hang-watchdog-entry.js')
// Why: electron-vite's unpackaged appPath is already out/main; appending out/main again would silently disable the watchdog in dev and E2E builds.
if (!isPackaged && pathExists(adjacentBuildEntry)) {
return adjacentBuildEntry
}
return join(basePath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
}
@@ -0,0 +1,26 @@
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
describe('resolveHangWatchdogWorkerPath', () => {
it('resolves packaged workers inside app.asar', () => {
const appPath = join('/apps', 'orca', 'app.asar')
expect(resolveHangWatchdogWorkerPath(appPath, true)).toBe(
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
)
})
it('uses an adjacent entry when the dev app path is out/main', () => {
const appPath = join('/repo', 'out', 'main')
const adjacent = join(appPath, 'main-thread-hang-watchdog-entry.js')
const pathExists = vi.fn((candidate: string) => candidate === adjacent)
expect(resolveHangWatchdogWorkerPath(appPath, false, pathExists)).toBe(adjacent)
})
it('resolves through out/main from a dev project root', () => {
const appPath = join('/repo', 'orca')
expect(resolveHangWatchdogWorkerPath(appPath, false, () => false)).toBe(
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
)
})
})
@@ -0,0 +1,14 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
export function resolveHangWatchdogWorkerPath(
appPath: string,
isPackaged: boolean,
pathExists: (candidate: string) => boolean = existsSync
): string {
const adjacentBuildEntry = join(appPath, 'main-thread-hang-watchdog-entry.js')
if (!isPackaged && pathExists(adjacentBuildEntry)) {
return adjacentBuildEntry
}
return join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
}
@@ -0,0 +1,12 @@
export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000
export const HANG_WATCHDOG_TIMEOUT_MS = 45_000
export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000
export type HangWatchdogWorkerData = {
parentPid: number
markerPath: string
timeoutMs: number
checkIntervalMs: number
}
export type MainToHangWatchdogWorkerMessage = { type: 'heartbeat' } | { type: 'shutdown' }
@@ -67,9 +67,6 @@ describe('recordHangObservation', () => {
})
})
// Why: this is the safety contract of the whole PR. Observing a hang must never kill, relaunch,
// or exit — a false positive would SIGKILL a live main thread mid-write. If a future change
// reintroduces recovery, this test must fail loudly rather than ship silently.
it('never spawns, signals, or exits', () => {
recordHangObservation({
parentPid: 4242,
@@ -1,20 +1,12 @@
// Forked with ELECTRON_RUN_AS_NODE from the main process. Watches heartbeats from the main
// thread; if they stop (e.g. the macOS 26 AppKit scene-update deadlock) it records a marker so
// the next launch can report the stall, and rewrites it if the heartbeats come back.
//
// Why no kill: a deadlocked main thread has already lost whatever sat in the persistence debounce
// window, so killing it recovers nothing the user could not recover by force-quitting. A false
// positive, though, would SIGKILL a live main thread that was merely blocked — most plausibly on
// I/O, i.e. exactly when writes are in flight. All of the downside sits in the misfire, so this
// measures first: `selfRecovered` counts the stalls a killer would have gotten wrong.
//
// Must never import electron.
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
import { isMainThread, parentPort, workerData } from 'node:worker_threads'
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
import { writeHangDetectionMarker } from './hang-detection-marker'
import type {
HangWatchdogWorkerData,
MainToHangWatchdogWorkerMessage
} from './hang-watchdog-worker-protocol'
const DEFAULT_TIMEOUT_MS = 45_000
const DEFAULT_CHECK_INTERVAL_MS = 5_000
// Observation only: a false positive must never kill a live main thread mid-write.
export function recordHangObservation(options: {
parentPid: number
markerPath: string
@@ -36,37 +28,63 @@ export function recordHangObservation(options: {
}
}
function runWatchdog(parentPid: number): void {
const markerPath = process.env.ORCA_HANG_WATCHDOG_MARKER_PATH ?? ''
const timeoutMs = Number(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS
const checkIntervalMs =
Number(process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS
const loop = createHangWatchdogChildLoop({
timeoutMs,
checkIntervalMs,
function runWatchdog(config: HangWatchdogWorkerData): void {
const port = parentPort
if (!port) {
return
}
const loop = createHangWatchdogDetectionLoop({
timeoutMs: config.timeoutMs,
checkIntervalMs: config.checkIntervalMs,
now: () => Date.now(),
onHangDetected: (unresponsiveMs) =>
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: false }),
recordHangObservation({
parentPid: config.parentPid,
markerPath: config.markerPath,
unresponsiveMs,
selfRecovered: false
}),
// Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile.
onHangResolved: (unresponsiveMs) =>
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: true })
recordHangObservation({
parentPid: config.parentPid,
markerPath: config.markerPath,
unresponsiveMs,
selfRecovered: true
})
})
process.on('message', (message) => {
const type = (message as { type?: string } | null)?.type
if (type === 'heartbeat') {
let checkTimer: ReturnType<typeof setInterval> | null = setInterval(
() => loop.tick(),
config.checkIntervalMs
)
port.on('message', (message: MainToHangWatchdogWorkerMessage) => {
if (message.type === 'heartbeat') {
loop.recordHeartbeat()
} else if (type === 'shutdown') {
process.exit(0)
} else if (message.type === 'shutdown') {
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = null
}
port.close()
}
})
// Why: a normal parent exit closes the IPC channel; the watchdog must not outlive it and misfire.
process.on('disconnect', () => process.exit(0))
setInterval(() => loop.tick(), checkIntervalMs)
}
const configuredParentPid = Number(process.env.ORCA_HANG_WATCHDOG_PARENT_PID)
if (Number.isInteger(configuredParentPid) && configuredParentPid > 0) {
runWatchdog(configuredParentPid)
function isHangWatchdogWorkerData(value: unknown): value is HangWatchdogWorkerData {
const data = value as Partial<HangWatchdogWorkerData> | null
return (
!!data &&
Number.isInteger(data.parentPid) &&
(data.parentPid ?? 0) > 0 &&
typeof data.markerPath === 'string' &&
Number.isFinite(data.timeoutMs) &&
(data.timeoutMs ?? 0) > 0 &&
Number.isFinite(data.checkIntervalMs) &&
(data.checkIntervalMs ?? 0) > 0
)
}
if (!isMainThread && isHangWatchdogWorkerData(workerData)) {
runWatchdog(workerData)
}
@@ -1,7 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
const { forkMock, appMock } = vi.hoisted(() => ({
forkMock: vi.fn(),
const { workerState, appMock } = vi.hoisted(() => ({
workerState: {
calls: [] as unknown[][],
instance: null as object | null,
error: null as Error | null
},
appMock: {
isPackaged: true,
getAppPath: vi.fn(() => '/apps/orca/app.asar'),
@@ -9,8 +14,16 @@ const { forkMock, appMock } = vi.hoisted(() => ({
}
}))
vi.mock('node:child_process', () => ({
fork: forkMock
vi.mock('node:worker_threads', () => ({
Worker: class WorkerMock {
constructor(...args: unknown[]) {
workerState.calls.push(args)
if (workerState.error) {
throw workerState.error
}
return workerState.instance as WorkerMock
}
}
}))
vi.mock('electron', () => ({
@@ -29,28 +42,33 @@ function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
}
}
function fakeChild() {
function fakeWorker() {
return {
connected: true,
stderr: { on: vi.fn() },
postMessage: vi.fn(),
unref: vi.fn(),
on: vi.fn(),
send: vi.fn(),
disconnect: vi.fn(),
kill: vi.fn()
once: vi.fn()
}
}
describe('installMainThreadHangWatchdog', () => {
beforeEach(() => {
vi.useFakeTimers()
forkMock.mockReset()
workerState.calls = []
workerState.instance = null
workerState.error = null
appMock.on.mockReset()
appMock.isPackaged = true
delete process.env.ORCA_HANG_WATCHDOG_FORCE
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
})
afterEach(() => {
vi.useRealTimers()
delete process.env.ORCA_HANG_WATCHDOG_FORCE
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
})
it('is a no-op off macOS', () => {
@@ -60,7 +78,7 @@ describe('installMainThreadHangWatchdog', () => {
expect(
withPlatform('linux', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).toBeNull()
expect(forkMock).not.toHaveBeenCalled()
expect(workerState.calls).toHaveLength(0)
})
it('is a no-op in unpackaged builds unless forced', () => {
@@ -69,62 +87,98 @@ describe('installMainThreadHangWatchdog', () => {
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).toBeNull()
process.env.ORCA_HANG_WATCHDOG_FORCE = '1'
const child = fakeChild()
forkMock.mockReturnValue(child)
const worker = fakeWorker()
workerState.instance = worker
expect(
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).not.toBeNull()
delete process.env.ORCA_HANG_WATCHDOG_FORCE
})
it('forks the watchdog as plain Node with pid, bundle, and marker config', () => {
const child = fakeChild()
forkMock.mockReturnValue(child)
it('starts a worker with pid, marker, and timing config', () => {
const worker = fakeWorker()
workerState.instance = worker
const handle = withPlatform('darwin', () =>
installMainThreadHangWatchdog({ userDataPath: '/ud' })
)
expect(handle).not.toBeNull()
const [, , options] = forkMock.mock.calls[0]
expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(options.env.ORCA_HANG_WATCHDOG_PARENT_PID).toBe(String(process.pid))
expect(options.env.ORCA_HANG_WATCHDOG_MARKER_PATH).toContain('/ud')
const [workerPath, rawOptions] = workerState.calls[0]
const options = rawOptions as {
name: string
workerData: {
parentPid: number
markerPath: string
timeoutMs: number
checkIntervalMs: number
}
}
expect(workerPath).toBe(
join('/apps/orca/app.asar', 'out', 'main', 'main-thread-hang-watchdog-entry.js')
)
expect(options.name).toBe('orca-main-thread-hang-watchdog')
expect(options.workerData).toMatchObject({
parentPid: process.pid,
markerPath: join('/ud', 'main-thread-hang.json'),
timeoutMs: 45_000,
checkIntervalMs: 5_000
})
expect(worker.unref).toHaveBeenCalled()
})
it('sends heartbeats on an interval and shutdown+disconnect on stop', () => {
const child = fakeChild()
forkMock.mockReturnValue(child)
it('sends heartbeats on an interval and shutdown on stop', () => {
const worker = fakeWorker()
workerState.instance = worker
const handle = withPlatform('darwin', () =>
installMainThreadHangWatchdog({ userDataPath: '/ud' })
)
vi.advanceTimersByTime(6_000)
const heartbeats = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
const heartbeats = worker.postMessage.mock.calls.filter(([m]) => m.type === 'heartbeat')
expect(heartbeats.length).toBe(3)
handle?.stop()
expect(child.send.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
expect(child.disconnect).toHaveBeenCalled()
expect(worker.postMessage.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
// Why: quit fires will-quit twice; a second stop must not resend or throw.
handle?.stop()
const shutdowns = child.send.mock.calls.filter(([m]) => m.type === 'shutdown')
const shutdowns = worker.postMessage.mock.calls.filter(([m]) => m.type === 'shutdown')
expect(shutdowns.length).toBe(1)
vi.advanceTimersByTime(10_000)
const heartbeatsAfterStop = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
const heartbeatsAfterStop = worker.postMessage.mock.calls.filter(
([m]) => m.type === 'heartbeat'
)
expect(heartbeatsAfterStop.length).toBe(3)
})
it('passes test timing overrides to the worker', () => {
process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS = '900'
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS = '100'
workerState.instance = fakeWorker()
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
const options = workerState.calls[0][1] as {
workerData: { timeoutMs: number; checkIntervalMs: number }
}
expect(options.workerData).toMatchObject({ timeoutMs: 900, checkIntervalMs: 100 })
})
it('registers stop on will-quit', () => {
const child = fakeChild()
forkMock.mockReturnValue(child)
workerState.instance = fakeWorker()
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
expect(appMock.on).toHaveBeenCalledWith('will-quit', expect.any(Function))
})
it('returns null and stays inert when the fork itself fails', () => {
forkMock.mockImplementation(() => {
throw new Error('spawn failure')
})
it('stops heartbeat work when the worker exits', () => {
const worker = fakeWorker()
workerState.instance = worker
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
const exitListener = worker.once.mock.calls.find(([event]) => event === 'exit')?.[1]
expect(exitListener).toEqual(expect.any(Function))
exitListener()
vi.advanceTimersByTime(6_000)
expect(worker.postMessage).not.toHaveBeenCalled()
})
it('returns null and stays inert when worker construction fails', () => {
workerState.error = new Error('worker failure')
expect(
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).toBeNull()
@@ -1,19 +1,25 @@
import { fork, type ChildProcess } from 'node:child_process'
import { Worker } from 'node:worker_threads'
import { app } from 'electron'
import { resolveHangWatchdogEntryPath } from './hang-watchdog-entry-path'
import { hangDetectionMarkerPath } from './hang-detection-marker'
const HEARTBEAT_INTERVAL_MS = 2_000
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
import {
HANG_WATCHDOG_CHECK_INTERVAL_MS,
HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS,
HANG_WATCHDOG_TIMEOUT_MS,
type HangWatchdogWorkerData,
type MainToHangWatchdogWorkerMessage
} from './hang-watchdog-worker-protocol'
export type MainThreadHangWatchdogHandle = {
stop: () => void
child: ChildProcess
worker: Worker
}
function positiveTiming(value: string | undefined, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
// Why: macOS 26 scene-backed AppKit windows can deadlock the main thread inside
// FrontBoardServices with no crash and no event loop left to self-report. A plain-Node sibling
// process watches heartbeats and records the stall so the next launch can report it. It observes
// only — see main-thread-hang-watchdog-entry.ts for why it does not kill the parent.
export function installMainThreadHangWatchdog(options: {
userDataPath: string
}): MainThreadHangWatchdogHandle | null {
@@ -24,58 +30,57 @@ export function installMainThreadHangWatchdog(options: {
if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') {
return null
}
const entryPath = resolveHangWatchdogEntryPath(app.getAppPath(), app.isPackaged)
let child: ChildProcess
const workerPath = resolveHangWatchdogWorkerPath(app.getAppPath(), app.isPackaged)
const workerData: HangWatchdogWorkerData = {
parentPid: process.pid,
markerPath: hangDetectionMarkerPath(options.userDataPath),
timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS),
checkIntervalMs: positiveTiming(
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS,
HANG_WATCHDOG_CHECK_INTERVAL_MS
)
}
let worker: Worker
try {
child = fork(entryPath, [], {
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
ORCA_HANG_WATCHDOG_MARKER_PATH: hangDetectionMarkerPath(options.userDataPath)
}
// Why: the worker survives an AppKit main-thread deadlock without another Electron process.
worker = new Worker(workerPath, {
name: 'orca-main-thread-hang-watchdog',
workerData
})
} catch (error) {
console.error('[hang-watchdog] failed to fork watchdog process:', error)
console.error('[hang-watchdog] failed to start watchdog worker:', error)
return null
}
child.stderr?.on('data', (chunk: Buffer) => {
console.error('[hang-watchdog]', String(chunk).trimEnd())
worker.on('error', (error) => {
console.error('[hang-watchdog] watchdog worker failed:', error)
})
// Why: the watchdog is a safety net — if it dies, run without it; a restart loop here must never affect the app.
child.on('error', () => {})
const heartbeatTimer = setInterval(() => {
if (child.connected) {
try {
child.send({ type: 'heartbeat' }, () => {})
} catch {
// Channel raced closed between the check and the send.
}
}
}, HEARTBEAT_INTERVAL_MS)
let stopped = false
const postMessage = (message: MainToHangWatchdogWorkerMessage): void => {
if (stopped && message.type === 'heartbeat') {
return
}
try {
worker.postMessage(message)
} catch {
// The worker already exited.
}
}
const heartbeatTimer = setInterval(() => {
postMessage({ type: 'heartbeat' })
}, HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS)
const stop = (): void => {
if (stopped) {
return
}
stopped = true
clearInterval(heartbeatTimer)
if (child.connected) {
try {
child.send({ type: 'shutdown' }, () => {})
} catch {
// Already disconnecting.
}
}
// Why: disconnect guarantees the child observes parent shutdown and exits instead of misreading quit as a hang.
try {
child.disconnect()
} catch {
// Channel already closed.
}
postMessage({ type: 'shutdown' })
}
// Why: will-quit fires twice during quit; stop is idempotent.
worker.once('exit', () => {
stopped = true
clearInterval(heartbeatTimer)
})
worker.unref()
app.on('will-quit', stop)
return { stop, child }
return { stop, worker }
}
+49
View File
@@ -44,6 +44,12 @@ import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
resolveAgentWorkspaceExecutionHostId,
sweepRestoredSubagentsWithoutLiveAgent
} from './agent-hooks/restored-subagent-liveness-sweep'
import {
isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS,
@@ -763,6 +769,46 @@ ipcMain.handle(
}
)
/** A PTY that dies while Orca is down never runs the teardown that clears pane
* state, so hydrate can rebuild a Claude subagent roster that no later hook can
* retire — pinning the pane 'working' and locking its agent out of hibernation
* for good. Once provider and hook hydration settle, targeted PTY liveness can
* retire only rows whose local owner is proven gone. */
async function reapRestoredSubagentsWithoutLiveAgent(): Promise<void> {
const currentStore = store
if (!currentStore) {
return
}
const provider = getDaemonProvider()
if (!provider) {
return
}
const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds(
currentStore.getWorkspaceSession().terminalLayoutsByTabId ?? {}
)
await sweepRestoredSubagentsWithoutLiveAgent({
probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId),
isLocalExecutionHost: (worktreeId) =>
isLocalExecutionHost(
resolveAgentWorkspaceExecutionHostId(worktreeId, {
getRepo: (repoId) => currentStore.getRepo(repoId),
getWorktreeMeta: (resolvedWorktreeId) => currentStore.getWorktreeMeta(resolvedWorktreeId),
getFolderWorkspace: (folderWorkspaceId) =>
currentStore.getFolderWorkspace(folderWorkspaceId),
getProjectGroups: () => currentStore.getProjectGroups()
})
),
getBoundPtyIdForPaneKey: getPtyIdForPaneKey,
getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey),
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalHost,
isLocalPaneAgentLive,
isLocalPaneLivenessEvidenceCurrent
)
})
}
function startTerminalRuntimeStartupServices(): Promise<void> {
logStartupMilestone('first-window-startup-services-start')
const startupServices = startFirstWindowStartupServices({
@@ -811,6 +857,9 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
})
void localPtyStartupReady.then(() => {
logStartupMilestone('local-pty-startup-ready')
void reapRestoredSubagentsWithoutLiveAgent().catch((error) => {
console.warn('[agent-hooks] restored-subagent liveness probe failed:', error)
})
})
return firstWindowStartupServicesReady
}
@@ -9,6 +9,8 @@ export type PendingPtyData = {
droppedOutput?: true
droppedMode2031Data?: string
droppedMode2031ScanState?: Mode2031ReplyScanState
projectionAdmissionIds?: readonly string[]
projectionAdmissionsTransferred?: true
}
export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked'
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import {
appendPendingProjectionAdmission,
compactPendingProjectionAdmissions,
PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS,
propagatePendingProjectionRemainder
} from './pty-pending-projection-admissions'
describe('pending PTY projection admissions', () => {
it('retains the exact cap then transfers the whole ordered run on overflow', () => {
const transfer = vi.fn()
let state = compactPendingProjectionAdmissions()
for (let index = 0; index < PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS; index++) {
state = appendPendingProjectionAdmission(state, `projection-${index}`, {
isPending: () => true,
transfer
})
}
expect(state.projectionAdmissionIds).toHaveLength(PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS)
expect(transfer).not.toHaveBeenCalled()
state = appendPendingProjectionAdmission(state, 'projection-overflow', {
isPending: () => true,
transfer
})
expect(state).toEqual({ projectionAdmissionsTransferred: true })
expect(transfer).toHaveBeenCalledOnce()
expect(transfer.mock.calls[0]?.[0]).toHaveLength(PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS + 1)
})
it('compacts terminal prefixes and transfers later admissions until the remainder drains', () => {
const transfer = vi.fn()
const compacted = propagatePendingProjectionRemainder(
{
projectionAdmissionIds: ['projection-published', 'projection-partial', 'projection-tail']
},
{ sent: true, projectionsTransferred: false },
{ isPending: (id) => id !== 'projection-published', transfer }
)
expect(compacted).toEqual({
projectionAdmissionIds: ['projection-partial', 'projection-tail']
})
const transferred = appendPendingProjectionAdmission(
{ projectionAdmissionsTransferred: true },
'projection-after-transfer',
{ isPending: () => true, transfer }
)
expect(transferred).toEqual({ projectionAdmissionsTransferred: true })
expect(transfer).toHaveBeenCalledWith(['projection-after-transfer'], 'pending-projection-cap')
expect(
propagatePendingProjectionRemainder(
compacted,
{ sent: true, projectionsTransferred: true },
{ isPending: () => true, transfer }
)
).toEqual({ projectionAdmissionsTransferred: true })
})
})
@@ -0,0 +1,57 @@
export const PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS = 1024
export type PendingProjectionAdmissions = Readonly<{
projectionAdmissionIds?: readonly string[]
projectionAdmissionsTransferred?: true
}>
type PendingProjectionAdmissionOptions = Readonly<{
isPending: (id: string) => boolean
transfer: (ids: readonly string[], reason: string) => void
}>
const DEFAULT_OPTIONS: PendingProjectionAdmissionOptions = {
isPending: () => true,
transfer: () => {}
}
export function compactPendingProjectionAdmissions(
state: PendingProjectionAdmissions = {},
options: PendingProjectionAdmissionOptions = DEFAULT_OPTIONS
): PendingProjectionAdmissions {
if (state.projectionAdmissionsTransferred) {
return { projectionAdmissionsTransferred: true }
}
const ids = Array.from(
new Set((state.projectionAdmissionIds ?? []).filter((id) => options.isPending(id)))
)
return ids.length > 0 ? { projectionAdmissionIds: ids } : {}
}
export function appendPendingProjectionAdmission(
state: PendingProjectionAdmissions,
id: string,
options: PendingProjectionAdmissionOptions
): PendingProjectionAdmissions {
if (state.projectionAdmissionsTransferred) {
options.transfer([id], 'pending-projection-cap')
return { projectionAdmissionsTransferred: true }
}
const compacted = compactPendingProjectionAdmissions(state, options)
const ids = [...(compacted.projectionAdmissionIds ?? []), id]
if (ids.length <= PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS) {
return { projectionAdmissionIds: ids }
}
options.transfer(ids, 'pending-projection-cap')
return { projectionAdmissionsTransferred: true }
}
export function propagatePendingProjectionRemainder(
state: PendingProjectionAdmissions,
delivery: Readonly<{ sent: boolean; projectionsTransferred: boolean }>,
options: PendingProjectionAdmissionOptions
): PendingProjectionAdmissions {
return delivery.sent && !delivery.projectionsTransferred
? compactPendingProjectionAdmissions(state, options)
: { projectionAdmissionsTransferred: true }
}
+198 -1
View File
@@ -247,6 +247,11 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
import {
acceptSshPtyOutputData,
acceptSshPtyOutputExit,
closeSshPtyOutputGeneration
} from './ssh-pty-output-intake-registry'
// Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161).
const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
@@ -449,7 +454,8 @@ describe('registerPtyHandlers', () => {
'ssh-reattach-1',
'ssh-reattach-fail',
'ssh-reattach-ok',
'ssh-runtime-env'
'ssh-runtime-env',
'ssh-generation-replacement'
]) {
unregisterSshPtyProvider(leakedConnectionId)
}
@@ -11556,6 +11562,197 @@ describe('registerPtyHandlers', () => {
}
})
it('keeps negotiated source-credit overflow off the legacy PTY-global pause path', async () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
let modelSequence = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => modelSequence),
onPtyData: vi.fn(
(_id: string, data: string, _at: number, rawLength = data.length) =>
(modelSequence += rawLength)
),
acceptPtyDataBounded: vi.fn(
(_id: string, _data: string, _at: number, rawLength: number) => {
modelSequence += rawLength
return { sequence: modelSequence, completion: Promise.resolve() }
}
)
}
registerPtyHandlers(mainWindow as never, runtime as never)
mainWindow.webContents.send.mockClear()
const sourceChunk = 's'.repeat(128 * 1024)
for (let index = 0; index < 17; index++) {
const sourceStartSu = index * sourceChunk.length
await acceptSshPtyOutputData({
id: 'source-credit-pty',
data: sourceChunk,
providerGeneration: 41,
ptyIncarnation: 'source-incarnation',
rawLength: sourceChunk.length,
transformed: false,
source: {
relayPtyId: 'relay-source-pty',
spanId: `source-token:${sourceStartSu}:${sourceStartSu + sourceChunk.length}`,
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'source-token',
sourceStartSu,
sourceEndSu: sourceStartSu + sourceChunk.length
}
})
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 0
})
expect(provider.pauseProducer).not.toHaveBeenCalledWith('source-credit-pty')
expect(provider.resumeProducer).not.toHaveBeenCalledWith('source-credit-pty')
provider.emitData('legacy-pty', 'l'.repeat(320 * 1024))
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
expect(provider.pauseProducer).toHaveBeenCalledWith('legacy-pty')
expect(provider.pauseProducer).not.toHaveBeenCalledWith('unrelated-pty')
vi.runAllTimers()
expect(provider.resumeProducer).toHaveBeenCalledTimes(1)
expect(provider.resumeProducer).toHaveBeenCalledWith('legacy-pty')
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('pauses and resumes the exact SSH provider generation across reconnect replacement', async () => {
vi.useFakeTimers()
const completion = makeDeferred()
let sequence = 0
let captures = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => sequence),
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
sequence += rawLength
captures++
return {
sequence,
completion: captures === 1 ? completion.promise : Promise.resolve()
}
})
}
const original = {
providerGeneration: 41,
hasPtyDeliveryPauseAdapter: () => true,
pauseProducer: vi.fn(),
resumeProducer: vi.fn()
}
const replacement = {
providerGeneration: 42,
hasPtyDeliveryPauseAdapter: () => true,
pauseProducer: vi.fn(),
resumeProducer: vi.fn()
}
const id = 'ssh:ssh-generation-replacement@@relay-pty'
const receipts: Promise<unknown>[] = []
try {
registerPtyHandlers(mainWindow as never, runtime as never)
registerSshPtyProvider('ssh-generation-replacement', original as never)
const running = acceptSshPtyOutputData({
id,
data: 'a'.repeat(256 * 1024),
providerGeneration: 41,
ptyIncarnation: 'incarnation-41',
rawLength: 256 * 1024,
transformed: false
})
receipts.push(running)
registerSshPtyProvider('ssh-generation-replacement', replacement as never)
const pressured = acceptSshPtyOutputData({
id,
data: 'b',
providerGeneration: 41,
ptyIncarnation: 'incarnation-41',
rawLength: 1,
transformed: false
})
receipts.push(pressured)
expect(original.pauseProducer).toHaveBeenCalledWith(id)
expect(replacement.pauseProducer).not.toHaveBeenCalled()
completion.resolve()
await Promise.all([running, pressured])
expect(original.resumeProducer).toHaveBeenCalledWith(id)
expect(replacement.resumeProducer).not.toHaveBeenCalled()
} finally {
completion.resolve()
await Promise.allSettled(receipts)
closeSshPtyOutputGeneration(41, 'test-cleanup')
unregisterSshPtyProvider('ssh-generation-replacement')
}
})
it('rejects local data while an SSH renderer exit waits for projection settlement', async () => {
const provider = installObservableDaemonTestProvider()
let sequence = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => sequence),
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
sequence += rawLength
return { sequence, completion: Promise.resolve() }
}),
onPtyData: vi.fn(),
onPtyExit: vi.fn()
}
const id = 'ssh:exit-data-race@@relay-pty'
registerPtyHandlers(mainWindow as never, runtime as never)
mainWindow.webContents.send.mockClear()
await acceptSshPtyOutputData({
id,
data: 'before-exit',
providerGeneration: 51,
ptyIncarnation: 'incarnation-51',
rawLength: 'before-exit'.length,
transformed: false
})
const exit = acceptSshPtyOutputExit({
id,
code: 0,
providerGeneration: 51,
ptyIncarnation: 'incarnation-51'
})
await Promise.resolve()
provider.emitData(id, 'must-not-follow-exit')
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', {
id,
data: 'must-not-follow-exit'
})
getPtyAckDataListener()(null, { id, processedChars: 'before-exit'.length })
await exit
expect(mainWindow.webContents.send.mock.calls.at(-1)).toEqual([
'pty:exit',
{
id,
code: 0,
providerGeneration: 51,
ptyIncarnation: 'incarnation-51'
}
])
})
it('resumes a paused producer when the PTY exits before draining', async () => {
vi.useFakeTimers()
try {
+471 -168
View File
@@ -158,6 +158,19 @@ import {
unmarkHiddenRendererPty
} from './pty-hidden-delivery-gate'
import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue'
import {
appendPendingProjectionAdmission,
compactPendingProjectionAdmissions,
propagatePendingProjectionRemainder,
type PendingProjectionAdmissions
} from './pty-pending-projection-admissions'
import { SshPtyOutputIntake } from './ssh-pty-output-intake'
import {
cancelSshPtySourceDelivery,
installSshPtyOutputIntake,
publishSshPtySourceAck
} from './ssh-pty-output-intake-registry'
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
import {
clearNativeWindowsConptyPty,
isNativeWindowsLocalPtySpawn,
@@ -202,6 +215,7 @@ type FreshLocalFallbackProvider = IPtyProvider & {
routesFreshSpawnsToLocalProvider?: true
}
const sshProviders = new Map<string, IPtyProvider>()
const sshProvidersByGeneration = new Map<number, IPtyProvider>()
type RegisteredPtyProvider = {
provider: IPtyProvider
@@ -1296,10 +1310,19 @@ function beginPtySpawnForWorktree(
/** Register an SSH PTY provider for a connection. */
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
sshProviders.set(connectionId, provider)
const generation = (provider as { providerGeneration?: number }).providerGeneration
if (Number.isSafeInteger(generation) && generation! > 0) {
sshProvidersByGeneration.set(generation!, provider)
}
}
/** Remove an SSH PTY provider when a connection is closed. */
export function unregisterSshPtyProvider(connectionId: string): void {
const provider = sshProviders.get(connectionId)
const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration
if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) {
sshProvidersByGeneration.delete(generation)
}
sshProviders.delete(connectionId)
}
@@ -1467,6 +1490,7 @@ let rendererDidStartLoadingHandler: (() => void) | null = null
// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops.
let rebindProviderListeners: (() => void) | null = null
let sshOutputIntakeCleanup: (() => void) | null = null
export function rebindLocalProviderListeners(): void {
rebindProviderListeners?.()
@@ -1840,8 +1864,10 @@ export function registerPtyHandlers(
},
() => isHiddenPtyDeliveryGateEnabled(getSettings?.())
)
let sshOutputIntake: SshPtyOutputIntake | null = null
// Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit.
const rendererExitingPtyIds = new Set<string>()
const rendererCreditBeforeExitByPty = new Map<string, boolean>()
const rendererDeliveryRestoreNeededPtys = new Set<string>()
function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) {
@@ -1927,11 +1953,18 @@ export function registerPtyHandlers(
pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id),
resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id)
})
const sourceCreditPendingPtys = new Set<string>()
function updateProducerFlowControl(id: string): void {
if (!PRODUCER_FLOW_CONTROL_ENABLED) {
return
}
if (sourceCreditPendingPtys.has(id)) {
if (pendingData.get(id)) {
return
}
sourceCreditPendingPtys.delete(id)
}
producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0)
}
@@ -2000,7 +2033,16 @@ export function registerPtyHandlers(
}
function clearPendingPtyData(): void {
for (const pending of pendingData.values()) {
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(
pending.projectionAdmissionIds,
'renderer-lifecycle-reset'
)
}
}
pendingData.clear()
sourceCreditPendingPtys.clear()
}
function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
@@ -2169,6 +2211,9 @@ export function registerPtyHandlers(
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
deliveryResyncUnansweredWarnLogged = false
for (const id of rendererDeliveryAccountingByPty.keys()) {
sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset')
}
rendererDeliveryAccountingByPty.clear()
rendererInFlightTotalChars = 0
clearPendingPtyData()
@@ -2265,6 +2310,9 @@ export function registerPtyHandlers(
accounting.lastAckAtMs = Date.now()
}
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged)
if (acknowledged > 0) {
sshOutputIntake?.settleProjectionPrefix(id, acknowledged)
}
return acknowledged
}
@@ -2336,6 +2384,12 @@ export function registerPtyHandlers(
// Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore.
const pending = pendingData.get(id)
if (pending) {
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(
pending.projectionAdmissionIds,
'renderer-delivery-writeoff'
)
}
pendingDroppedChars += pending.data.length
deletePendingPtyData(id)
pendingOverflowMarkedPtys.delete(id)
@@ -2365,7 +2419,11 @@ export function registerPtyHandlers(
return writtenOff
}
function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): boolean {
function sendPtyDataToRenderer(
id: string,
payload: PtyDataPayload,
projectionAdmissionIds?: readonly string[]
): { sent: boolean; projectionsTransferred: boolean } {
const charCount = getPtyPayloadCharCount(payload)
const accounting = rendererDeliveryAccountingByPty.get(id)
const hadAccounting = accounting !== undefined
@@ -2400,12 +2458,28 @@ export function registerPtyHandlers(
}
}
rendererDeliveryRestoreNeededPtys.add(id)
if (projectionAdmissionIds) {
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed')
}
mainDeliveryBreadcrumbs.record('pty-data-send-failed', {
id: redactPtyIdForDiagnostics(id),
chars: charCount
})
console.error('[pty] renderer data send failed; payload will not be retried', error)
return false
return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined }
}
let projectionsTransferred = false
if (projectionAdmissionIds) {
try {
sshOutputIntake?.publishProjectionPrefix(
projectionAdmissionIds,
payload.data.length,
charCount
)
} catch {
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed')
projectionsTransferred = true
}
}
if (rendererDeliveryRestoreNeededPtys.has(id)) {
try {
@@ -2418,7 +2492,7 @@ export function registerPtyHandlers(
)
}
}
return true
return { sent: true, projectionsTransferred }
}
function rendererPtyIsKnownHidden(id: string): boolean {
@@ -2521,6 +2595,9 @@ export function registerPtyHandlers(
pendingOverflowMarkedPtys.add(id)
}
pendingDroppedChars += pending.data.length
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap')
}
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
return {
@@ -2531,6 +2608,39 @@ export function registerPtyHandlers(
}
}
function updatePendingProjectionAdmissions(
pending: PendingPtyData,
state: PendingProjectionAdmissions
): void {
delete pending.projectionAdmissionIds
delete pending.projectionAdmissionsTransferred
if (state.projectionAdmissionIds) {
pending.projectionAdmissionIds = state.projectionAdmissionIds
}
if (state.projectionAdmissionsTransferred) {
pending.projectionAdmissionsTransferred = true
}
}
function compactPendingProjectionState(
pending: PendingProjectionAdmissions,
projectionSemanticsId?: string
): PendingProjectionAdmissions {
const options = pendingProjectionAdmissionOptions()
const compacted = compactPendingProjectionAdmissions(pending, options)
return projectionSemanticsId
? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options)
: compacted
}
function pendingProjectionAdmissionOptions() {
return {
isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false,
transfer: (ids: readonly string[], reason: string) =>
sshOutputIntake?.transferProjections(ids, reason)
}
}
function appendPendingPtyData(
id: string,
existing: PendingPtyData | undefined,
@@ -2539,10 +2649,14 @@ export function registerPtyHandlers(
preservesSeq: boolean,
containsBackgroundOutput: boolean,
rawLength = data.length,
transformed = false
transformed = false,
projectionSemanticsId?: string
): PendingPtyData {
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
if (existing?.droppedOutput === true) {
if (projectionSemanticsId) {
sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap')
}
const mode2031 = scanDroppedMode2031Data(
data,
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
@@ -2559,16 +2673,19 @@ export function registerPtyHandlers(
droppedMode2031ScanState: mode2031.state
}
}
const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId)
const nextContainsBackgroundOutput =
existing?.containsBackgroundOutput === true || containsBackgroundOutput
if (!existing) {
return dropOversizedPendingPtyData(id, {
const pending: PendingPtyData = {
data,
...(typeof startSeq === 'number' ? { startSeq } : {}),
...(rawLength !== data.length ? { rawLength } : {}),
...(transformed ? { transformed: true } : {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
})
}
updatePendingProjectionAdmissions(pending, projectionState)
return dropOversizedPendingPtyData(id, pending)
}
const existingRawLength = existing.rawLength ?? existing.data.length
const next: PendingPtyData = {
@@ -2578,6 +2695,7 @@ export function registerPtyHandlers(
: {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
}
updatePendingProjectionAdmissions(next, projectionState)
if (typeof existing.startSeq === 'number') {
next.startSeq = existing.startSeq
}
@@ -2661,6 +2779,9 @@ export function registerPtyHandlers(
pendingOverflowMarkedPtys.delete(id)
updateProducerFlowControl(id)
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
}
warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
@@ -2676,11 +2797,15 @@ export function registerPtyHandlers(
updateProducerFlowControl(id)
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
if (
!sendPtyDataToRenderer(id, {
!sendPtyDataToRenderer(
id,
data: pending.data + getDroppedMode2031RendererData(pending),
droppedOutput: true
})
{
id,
data: pending.data + getDroppedMode2031RendererData(pending),
droppedOutput: true
},
pending.projectionAdmissionIds
).sent
) {
sendFailed = true
break
@@ -2692,33 +2817,50 @@ export function registerPtyHandlers(
const indivisible = pending.transformed === true
const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
let nextPending: PendingPtyData | undefined
if (remaining) {
const nextPending: PendingPtyData = { data: remaining }
nextPending = { data: remaining }
if (typeof pending.startSeq === 'number') {
nextPending.startSeq = pending.startSeq + chunk.length
}
if (pending.containsBackgroundOutput === true) {
nextPending.containsBackgroundOutput = true
}
if (pending.projectionAdmissionIds) {
nextPending.projectionAdmissionIds = pending.projectionAdmissionIds
}
if (pending.projectionAdmissionsTransferred) {
nextPending.projectionAdmissionsTransferred = true
}
pendingData.replaceWithRemainder(selection, nextPending)
} else {
pendingData.remove(selection)
pendingOverflowMarkedPtys.delete(id)
}
updateProducerFlowControl(id)
if (
!sendPtyDataToRenderer(
const delivery = sendPtyDataToRenderer(
id,
makePtyDataPayload(
id,
makePtyDataPayload(
id,
chunk,
pending.startSeq,
pending.containsBackgroundOutput,
pending.rawLength,
pending.transformed
chunk,
pending.startSeq,
pending.containsBackgroundOutput,
pending.rawLength,
pending.transformed
),
pending.projectionAdmissionIds
)
if (nextPending) {
updatePendingProjectionAdmissions(
nextPending,
propagatePendingProjectionRemainder(
nextPending,
delivery,
pendingProjectionAdmissionOptions()
)
)
) {
}
if (!delivery.sent) {
sendFailed = true
break
}
@@ -2781,27 +2923,45 @@ export function registerPtyHandlers(
return true
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null {
if (mainWindow.isDestroyed()) {
return
sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed')
return () => {}
}
if (rendererExitingPtyIds.has(payload.id)) {
return
return null
}
rendererExitingPtyIds.add(payload.id)
let released = false
const release = (): void => {
if (released) {
return
}
released = true
rendererExitingPtyIds.delete(payload.id)
}
try {
const hadReleasableRendererCredit = getRendererInFlightCharsForPty(payload.id) > 0
if (!rendererCreditBeforeExitByPty.has(payload.id)) {
rendererCreditBeforeExitByPty.set(
payload.id,
getRendererInFlightCharsForPty(payload.id) > 0
)
}
// Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost.
const remaining = pendingData.delete(payload.id)
clearFlushTimerIfIdle()
if (remaining) {
if (remaining.droppedOutput === true) {
// Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped.
sendPtyDataToRenderer(payload.id, {
id: payload.id,
data: remaining.data,
droppedOutput: true
})
sendPtyDataToRenderer(
payload.id,
{
id: payload.id,
data: remaining.data,
droppedOutput: true
},
remaining.projectionAdmissionIds
)
} else {
sendPtyDataToRenderer(
payload.id,
@@ -2812,35 +2972,63 @@ export function registerPtyHandlers(
remaining.containsBackgroundOutput,
remaining.rawLength,
remaining.transformed
)
),
remaining.projectionAdmissionIds
)
}
}
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
producerFlowControl.release(payload.id)
pendingOverflowMarkedPtys.delete(payload.id)
rendererDeliveryRestoreNeededPtys.delete(payload.id)
lastInputAtByPty.delete(payload.id)
interactiveOutputCharsByPty.delete(payload.id)
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
rendererDeliveryAccountingByPty.delete(payload.id)
if (hadReleasableRendererCredit) {
if (pendingDataFlushActive) {
// Why: let the open round coalesce this wake into its one post-round continuation.
const reactivatedBlocked = pendingData.reactivateBlocked()
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
} else {
schedulePendingDataAfterCreditReport(true)
}
return release
} catch (error) {
release()
throw error
}
}
function finalizePtyExitForRenderer(payload: { id: string; code: number }): void {
if (mainWindow.isDestroyed()) {
rendererCreditBeforeExitByPty.delete(payload.id)
return
}
const hadReleasableRendererCredit =
rendererCreditBeforeExitByPty.get(payload.id) ??
getRendererInFlightCharsForPty(payload.id) > 0
rendererCreditBeforeExitByPty.delete(payload.id)
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
producerFlowControl.release(payload.id)
sourceCreditPendingPtys.delete(payload.id)
pendingOverflowMarkedPtys.delete(payload.id)
rendererDeliveryRestoreNeededPtys.delete(payload.id)
lastInputAtByPty.delete(payload.id)
interactiveOutputCharsByPty.delete(payload.id)
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
rendererDeliveryAccountingByPty.delete(payload.id)
if (hadReleasableRendererCredit) {
if (pendingDataFlushActive) {
// Why: let the open round coalesce this wake into its one post-round continuation.
const reactivatedBlocked = pendingData.reactivateBlocked()
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
} else {
schedulePendingDataAfterCreditReport(true)
}
mainWindow.webContents.send('pty:exit', {
...payload,
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
})
}
mainWindow.webContents.send('pty:exit', {
...payload,
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
})
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
const release = preparePtyExitForRenderer(payload)
if (!release) {
return
}
try {
sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit')
finalizePtyExitForRenderer(payload)
} finally {
rendererExitingPtyIds.delete(payload.id)
release()
}
}
@@ -2850,6 +3038,231 @@ export function registerPtyHandlers(
}
}
function acceptPtyDataForRenderer(
payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
},
outputSeq: number | undefined,
projection?: LegacySshProjectionSemantics
): void {
const rawLength = payload.sequenceChars ?? payload.data.length
const preservesSeq = !payload.transformed && rawLength === payload.data.length
const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
const projectionId = projection?.identity.projectionSemanticsId
if (mainWindow.isDestroyed()) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed')
}
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
clearPendingPtyData()
pendingOverflowMarkedPtys.clear()
rendererDeliveryAccountingByPty.clear()
rendererInFlightTotalChars = 0
clearDispatcherReadyWatchdog()
return
}
if (rendererExitingPtyIds.has(payload.id)) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'pty-exiting')
}
return
}
if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'hidden-drop')
}
const droppedChars = projection ? rawLength : payload.data.length
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
}
return
}
if (payload.data.length === 0 && !payload.transformed) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'empty-projection')
}
return
}
const containsBackgroundOutput =
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
if (containsBackgroundOutput) {
markHiddenRendererResizeOutputDelivered(payload.id)
}
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
if (projection?.desktopSpan) {
sourceCreditPendingPtys.add(payload.id)
}
const pending = appendPendingPtyData(
payload.id,
pendingData.get(payload.id),
payload.data,
startSeq,
preservesSeq,
containsBackgroundOutput,
rawLength,
payload.transformed === true,
projectionId
)
const shouldEmitPendingCapRestoreMarker =
pending.droppedOutput === true &&
!overflowMarkedBeforeAppend &&
pendingOverflowMarkedPtys.has(payload.id)
const nextData = pending.data + getDroppedMode2031RendererData(pending)
const isInteractiveOutput = shouldSendInteractiveOutputNow(
payload.id,
nextData,
performance.now()
)
if (isInteractiveOutput && rendererPtyDispatcherReady) {
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
requestDeliveryResyncForGatedPty()
return
}
deletePendingPtyData(payload.id)
clearFlushTimerIfIdle()
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
pendingOverflowMarkedPtys.delete(payload.id)
try {
sendPtyDataToRenderer(
payload.id,
{
id: payload.id,
data: nextData,
...(typeof pending.startSeq === 'number'
? {
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
rawLength: pending.rawLength ?? nextData.length
}
: {}),
...(pending.transformed ? { transformed: true } : {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
},
pending.projectionAdmissionIds
)
} finally {
updateProducerFlowControl(payload.id)
}
return
}
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
if (
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
) {
requestDeliveryResyncForGatedPty()
}
if (!flushTimer) {
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
}
}
sshOutputIntakeCleanup?.()
sshOutputIntake = new SshPtyOutputIntake({
getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0,
acceptModel: (event, projection) => {
if (!runtime) {
throw new Error('SSH PTY output requires the main terminal model')
}
return runtime.acceptPtyDataBounded(
event.id,
event.data,
Date.now(),
event.rawLength,
event.transformed,
projection.desktopSpan ? [projection.desktopSpan] : undefined
)
},
project: (event, projection) =>
acceptPtyDataForRenderer(
{
id: event.id,
data: event.data,
sequenceChars: event.rawLength,
transformed: event.transformed
},
projection.identity.sequenceEnd,
projection
),
prepareExit: (event) => {
const release = preparePtyExitForRenderer(event)
if (!release) {
throw new Error('pty_renderer_exit_in_progress')
}
return release
},
finalizeExit: (event) => {
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
finalizePtyExitForRenderer(event)
},
pauseProvider: (generation, id) => {
const provider = sshProvidersByGeneration.get(generation) as
| (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean })
| undefined
if (!provider?.hasPtyDeliveryPauseAdapter?.()) {
return false
}
provider.pauseProducer?.(id)
return true
},
resumeProvider: (generation, id) =>
sshProvidersByGeneration.get(generation)?.resumeProducer?.(id),
closeProvider: (generation, reason) => {
const provider = sshProvidersByGeneration.get(generation)
;(
provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined
)?.closeOutputIntake?.(reason)
},
resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id),
onGenerationClosed: (providerGeneration) => {
for (const id of pendingData.keys()) {
const pending = pendingData.get(id)
if (
pending?.projectionAdmissionIds &&
sshOutputIntake?.hasProjectionFromGeneration(
pending.projectionAdmissionIds,
providerGeneration
)
) {
pendingData.delete(id)
updateProducerFlowControl(id)
pendingOverflowMarkedPtys.delete(id)
}
}
sshProvidersByGeneration.delete(providerGeneration)
},
publishSourceAck: publishSshPtySourceAck,
cancelSourceDelivery: cancelSshPtySourceDelivery
})
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(
sshOutputIntake.getRemoteSourceRangeConsumerHooks()
)
const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake)
sshOutputIntakeCleanup = () => {
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null)
cleanupSshOutputIntakeRegistry()
}
async function shutdownProviderAndDetectExit(
provider: IPtyProvider,
id: string,
@@ -2933,120 +3346,7 @@ export function registerPtyHandlers(
const outputSeq = isLocalProvider
? runtime?.getPtyOutputSequence(payload.id)
: runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed)
const rendererData = payload.data
const preservesSeq = !payload.transformed && rawLength === payload.data.length
const startSeq =
typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
if (mainWindow.isDestroyed()) {
// Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration.
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
clearPendingPtyData()
pendingOverflowMarkedPtys.clear()
rendererDeliveryAccountingByPty.clear()
rendererInFlightTotalChars = 0
clearDispatcherReadyWatchdog()
return
}
if (rendererExitingPtyIds.size > 0 && rendererExitingPtyIds.has(payload.id)) {
return
}
const settings = getSettings?.()
// Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot.
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
}
return
}
if (rendererData.length === 0 && !payload.transformed) {
return
}
const containsBackgroundOutput =
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
if (containsBackgroundOutput) {
markHiddenRendererResizeOutputDelivered(payload.id)
}
const existing = pendingData.get(payload.id)
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
const pending = appendPendingPtyData(
payload.id,
existing,
rendererData,
startSeq,
preservesSeq,
containsBackgroundOutput,
rawLength,
payload.transformed === true
)
const shouldEmitPendingCapRestoreMarker =
pending.droppedOutput === true &&
!overflowMarkedBeforeAppend &&
pendingOverflowMarkedPtys.has(payload.id)
const nextData = pending.data + getDroppedMode2031RendererData(pending)
const isInteractiveOutput = shouldSendInteractiveOutputNow(
payload.id,
nextData,
performance.now()
)
// Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate.
if (isInteractiveOutput && rendererPtyDispatcherReady) {
// Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway.
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
requestDeliveryResyncForGatedPty()
return
}
deletePendingPtyData(payload.id)
clearFlushTimerIfIdle()
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
pendingOverflowMarkedPtys.delete(payload.id)
// Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency.
try {
sendPtyDataToRenderer(payload.id, {
id: payload.id,
data: nextData,
...(typeof pending.startSeq === 'number'
? {
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
rawLength: pending.rawLength ?? nextData.length
}
: {}),
...(pending.transformed ? { transformed: true } : {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
})
} finally {
updateProducerFlowControl(payload.id)
}
return
}
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
// Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable.
if (
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
) {
requestDeliveryResyncForGatedPty()
}
if (!flushTimer) {
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
}
acceptPtyDataForRenderer(payload, outputSeq)
})
localExitUnsub = localProvider.onExit((payload) => {
if (!isCurrentPtyExit(payload)) {
@@ -5646,6 +5946,9 @@ export function registerPtyHandlers(
const pending = pendingData.get(args.id)
if (pending && transition.droppable) {
pendingData.delete(args.id)
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
}
updateProducerFlowControl(args.id)
pendingOverflowMarkedPtys.delete(args.id)
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
@@ -0,0 +1,53 @@
type ClosedGenerationRange = {
start: number
end: number
}
export class SshPtyClosedGenerationRanges {
private readonly ranges: ClosedGenerationRange[] = []
add(generation: number): void {
let index = 0
while (index < this.ranges.length && this.ranges[index]!.end + 1 < generation) {
index++
}
const current = this.ranges[index]
if (!current || generation + 1 < current.start) {
this.ranges.splice(index, 0, { start: generation, end: generation })
return
}
current.start = Math.min(current.start, generation)
current.end = Math.max(current.end, generation)
const next = this.ranges[index + 1]
if (next && current.end + 1 >= next.start) {
current.end = Math.max(current.end, next.end)
this.ranges.splice(index + 1, 1)
}
}
has(generation: number): boolean {
for (const range of this.ranges) {
if (generation < range.start) {
return false
}
if (generation <= range.end) {
return true
}
}
return false
}
get size(): number {
return this.ranges.length
}
get activeGaps(): number {
const highWater = this.ranges.at(-1)?.end ?? 0
let closedGenerations = 0
for (const range of this.ranges) {
closedGenerations += range.end - range.start + 1
}
// Why: provider generations allocate from 1, so unclosed IDs below high-water remain active.
return highWater - closedGenerations
}
}
@@ -0,0 +1,115 @@
import {
projectionError,
reclaimProjectionRecord,
type DesktopProjectionSpan,
type ProjectionRecord
} from './ssh-pty-legacy-projection-record'
export function publishLegacyProjectionPrefix(
records: Map<string, ProjectionRecord>,
ids: readonly string[],
displayChars: number,
accountingChars: number
): void {
let displayRemaining = Math.max(0, displayChars)
let accountingRemaining = Math.max(0, accountingChars)
for (const id of ids) {
const record = records.get(id)
if (!record) {
continue
}
const displayLength =
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
const unpublishedDisplay = displayLength - record.publishedDisplay
const unpublishedAccounting = record.semantics.identity.rawLength - record.publishedAccounting
if (displayLength === 0 && unpublishedAccounting > 0) {
const publishAccounting = Math.min(accountingRemaining, unpublishedAccounting)
if (publishAccounting !== unpublishedAccounting) {
throw projectionError('ssh_projection_indivisible_split')
}
record.publishedAccounting += publishAccounting
record.state = 'published'
accountingRemaining -= publishAccounting
continue
}
if (unpublishedDisplay <= 0) {
continue
}
const publishDisplay = Math.min(displayRemaining, unpublishedDisplay)
if (publishDisplay <= 0) {
break
}
if (record.semantics.identity.transformed && publishDisplay !== unpublishedDisplay) {
throw projectionError('ssh_projection_indivisible_split')
}
const publishAccounting =
publishDisplay === unpublishedDisplay
? Math.min(accountingRemaining, unpublishedAccounting)
: publishDisplay
record.publishedDisplay += publishDisplay
record.publishedAccounting += publishAccounting
record.state = 'published'
displayRemaining -= publishDisplay
accountingRemaining -= publishAccounting
}
if (displayRemaining !== 0 || accountingRemaining !== 0) {
throw projectionError('ssh_projection_publish_range_mismatch')
}
}
export function hasUnpublishedLegacyProjection(
records: ReadonlyMap<string, ProjectionRecord>,
id: string
): boolean {
const record = records.get(id)
if (!record || record.state === 'reserved') {
return false
}
const displayLength =
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
return (
record.publishedDisplay < displayLength ||
record.publishedAccounting < record.semantics.identity.rawLength
)
}
export function settlePublishedLegacyProjectionPrefix(
records: Map<string, ProjectionRecord>,
idsByPty: Map<string, string[]>,
ptyId: string,
accountingChars: number,
onSettled: ((span: DesktopProjectionSpan, reason: string) => void) | undefined
): { settled: number; completed: number } {
let remaining = Math.max(0, accountingChars)
let settled = 0
let completed = 0
for (const id of idsByPty.get(ptyId)?.slice() ?? []) {
const record = records.get(id)
if (!record) {
continue
}
const available = record.publishedAccounting - record.settledAccounting
if (available <= 0) {
continue
}
const take = Math.min(remaining, available)
const finishes =
record.settledAccounting + take === record.semantics.identity.rawLength &&
record.publishedDisplay ===
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
if (finishes && record.semantics.desktopSpan) {
onSettled?.(record.semantics.desktopSpan, 'renderer-parse')
}
record.settledAccounting += take
settled += take
remaining -= take
if (finishes) {
completed++
reclaimProjectionRecord(records, idsByPty, id, ptyId)
}
if (remaining === 0) {
break
}
}
return { settled, completed }
}
@@ -0,0 +1,218 @@
import type {
Mode2031ReplyDecision,
Mode2031ReplyScanState
} from '../../shared/terminal-color-scheme-protocol'
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../shared/terminal-color-scheme-protocol'
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
export type LegacySshProjectionIdentity = Readonly<{
projectionSemanticsId: string
ptyId: string
providerGeneration: number
ptyIncarnation: string
displayStart: number
displayEnd: number
sequenceEnd: number
rawLength: number
transformed: boolean
}>
export type LegacySshProjectionSemantics = Readonly<{
identity: LegacySshProjectionIdentity
desktopSpan?: DesktopProjectionSpan
beforeScanner: Readonly<Mode2031ReplyScanState>
afterScanner: Readonly<Mode2031ReplyScanState>
decision: Mode2031ReplyDecision
}>
export type DesktopProjectionSpan = Readonly<
TerminalOutputSourceRange & {
projectionSemanticsId: string
transform: Readonly<{
transformed: boolean
rawLengthSu: number
scalarSafe: boolean
}>
}
>
export type ProjectionState = 'reserved' | 'committed' | 'published'
export type ProjectionRecord = {
semantics: LegacySshProjectionSemantics
state: ProjectionState
publishedDisplay: number
publishedAccounting: number
settledAccounting: number
}
export type PtyProjectionCursor = {
providerGeneration: number
ptyIncarnation: string
displayEnd: number
scanner: Mode2031ReplyScanState
}
export type LegacySshProjectionReservation = Readonly<{
semantics: LegacySshProjectionSemantics
}>
export type LegacySshProjectionDebugSnapshot = {
reserved: number
committed: number
published: number
settled: number
transferred: number
rolledBack: number
records: number
cursors: number
}
export function scannerSnapshot(state: Mode2031ReplyScanState): Readonly<Mode2031ReplyScanState> {
return Object.freeze({ tail: state.tail, pendingSubscribe: state.pendingSubscribe })
}
export function resetProjectionCursorForGap(
cursors: ReadonlyMap<string, PtyProjectionCursor>,
ptyId: string
): void {
const cursor = cursors.get(ptyId)
if (cursor) {
cursor.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
}
}
export function projectionError(code: string): Error {
return Object.assign(new Error(code), { code })
}
export function projectionDebugSnapshot(
records: ReadonlyMap<string, ProjectionRecord>,
cursors: ReadonlyMap<string, PtyProjectionCursor>,
terminalCounts: { settled: number; transferred: number; rolledBack: number }
): LegacySshProjectionDebugSnapshot {
const result = {
reserved: 0,
committed: 0,
published: 0,
...terminalCounts,
records: records.size,
cursors: cursors.size
}
for (const record of records.values()) {
result[record.state]++
}
return result
}
export function reclaimProjectionRecord(
records: Map<string, ProjectionRecord>,
idsByPty: Map<string, string[]>,
id: string,
ptyId: string
): void {
records.delete(id)
const ids = idsByPty.get(ptyId)
if (!ids) {
return
}
const index = ids.indexOf(id)
if (index >= 0) {
ids.splice(index, 1)
}
if (ids.length === 0) {
idsByPty.delete(ptyId)
}
}
export function requireProjectionRecord(
records: ReadonlyMap<string, ProjectionRecord>,
id: string
): ProjectionRecord {
const record = records.get(id)
if (!record) {
throw projectionError('ssh_projection_reservation_missing')
}
return record
}
export function rollbackCommittedProjectionRecord(
records: Map<string, ProjectionRecord>,
idsByPty: Map<string, string[]>,
cursors: Map<string, PtyProjectionCursor>,
reservation: LegacySshProjectionReservation
): string | null {
const id = reservation.semantics.identity.projectionSemanticsId
const record = records.get(id)
if (!record || record.state !== 'committed') {
return null
}
const { identity, beforeScanner } = record.semantics
const cursor = cursors.get(identity.ptyId)
if (
idsByPty.get(identity.ptyId)?.at(-1) !== id ||
!cursor ||
cursor.providerGeneration !== identity.providerGeneration ||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
cursor.displayEnd !== identity.displayEnd
) {
return null
}
cursor.displayEnd = identity.displayStart
cursor.scanner = { ...beforeScanner }
reclaimProjectionRecord(records, idsByPty, id, identity.ptyId)
return identity.ptyId
}
export function closeProjectionPty(
cursors: Map<string, PtyProjectionCursor>,
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
beforeDelete: () => void
): void {
const cursor = cursors.get(ptyId)
if (
!cursor ||
cursor.providerGeneration !== providerGeneration ||
cursor.ptyIncarnation !== ptyIncarnation
) {
return
}
beforeDelete()
cursors.delete(ptyId)
}
export function getOrCreateProjectionCursor(
cursors: Map<string, PtyProjectionCursor>,
args: { ptyId: string; providerGeneration: number; ptyIncarnation: string },
replaceGeneration: (providerGeneration: number) => void
): PtyProjectionCursor {
const existing = cursors.get(args.ptyId)
if (!existing) {
const cursor = {
providerGeneration: args.providerGeneration,
ptyIncarnation: args.ptyIncarnation,
displayEnd: 0,
scanner: { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
}
cursors.set(args.ptyId, cursor)
return cursor
}
if (args.providerGeneration < existing.providerGeneration) {
throw projectionError('ssh_projection_stale_generation')
}
if (
args.providerGeneration === existing.providerGeneration &&
args.ptyIncarnation !== existing.ptyIncarnation
) {
throw projectionError('ssh_projection_stale_incarnation')
}
if (args.providerGeneration > existing.providerGeneration) {
replaceGeneration(existing.providerGeneration)
existing.providerGeneration = args.providerGeneration
existing.ptyIncarnation = args.ptyIncarnation
existing.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
}
return existing
}
@@ -0,0 +1,244 @@
import { describe, expect, it } from 'vitest'
import { SshPtyLegacyProjectionLedger } from './ssh-pty-legacy-projection'
import { SshPtyProjectionTerminality } from './ssh-pty-projection-terminality'
function reserve(
ledger: SshPtyLegacyProjectionLedger,
overrides: Partial<Parameters<SshPtyLegacyProjectionLedger['reserve']>[0]> = {}
) {
return ledger.reserve({
ptyId: 'pty-1',
providerGeneration: 3,
ptyIncarnation: 'incarnation-1',
data: 'abc',
sequenceEnd: 3,
rawLength: 3,
transformed: false,
...overrides
})
}
function source(sourceStartSu: number, sourceEndSu: number, deliveryToken = 'token-1') {
return {
spanId: `span-${sourceStartSu}`,
clientGeneration: 2,
ownerGeneration: 4,
deliveryToken,
sourceStartSu,
sourceEndSu
}
}
describe('SshPtyLegacyProjectionLedger', () => {
it('drains exact terminality waiters when their provider generation closes', async () => {
const terminality = new SshPtyProjectionTerminality()
const terminal = terminality.whenTerminal('pty-1', 3, 'incarnation-1', () => true)
let nextResolved = false
const next = terminality
.whenTerminal('pty-1', 4, 'incarnation-2', () => true)
.then(() => {
nextResolved = true
})
terminality.closeGeneration(3)
await expect(terminal).resolves.toBeUndefined()
expect(nextResolved).toBe(false)
terminality.closeGeneration(4)
await next
})
it('rolls back scanner and display reservations before commit', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const partial = reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 })
expect(ledger.rollback(partial)).toBe(true)
const next = reserve(ledger, { data: '31h', sequenceEnd: 3 })
expect(next.semantics.identity.displayStart).toBe(0)
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
expect(next.semantics.decision).toBeNull()
})
it('keeps immutable generation, incarnation, display, sequence, raw length, and scanner facts', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const first = reserve(ledger, {
data: '\x1b[?2031h',
rawLength: 11,
sequenceEnd: 11
})
const semantics = ledger.commit(first)
expect(semantics.identity).toMatchObject({
providerGeneration: 3,
ptyIncarnation: 'incarnation-1',
displayStart: 0,
displayEnd: 8,
sequenceEnd: 11,
rawLength: 11
})
expect(semantics.decision).toBe('subscribed')
expect(Object.isFrozen(semantics)).toBe(true)
expect(Object.isFrozen(semantics.identity)).toBe(true)
})
it('rejects stale generations and resets cross-chunk scanner state on gaps', () => {
const ledger = new SshPtyLegacyProjectionLedger()
ledger.commit(reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 }))
ledger.resetForGap('pty-1')
const next = reserve(ledger, { data: '31h', sequenceEnd: 8 })
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
expect(() => reserve(ledger, { providerGeneration: 2 })).toThrow(
'ssh_projection_stale_generation'
)
})
it('publishes, settles, and transfers explicit ranges', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const first = ledger.commit(reserve(ledger))
ledger.publishPrefix([first.identity.projectionSemanticsId], 3, 3)
expect(ledger.settlePublishedPrefix('pty-1', 2)).toBe(2)
expect(ledger.transfer([first.identity.projectionSemanticsId], 'renderer-reload')).toBe(1)
expect(ledger.getDebugSnapshot()).toMatchObject({ transferred: 1, records: 0 })
})
it('resolves exact PTY terminal waiters only after settlement or transfer', async () => {
const ledger = new SshPtyLegacyProjectionLedger()
const projection = ledger.commit(reserve(ledger))
ledger.publishPrefix([projection.identity.projectionSemanticsId], 3, 3)
let settled = false
const terminal = ledger.whenPtyTerminal('pty-1', 3, 'incarnation-1').then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
ledger.settlePublishedPrefix('pty-1', 2)
await Promise.resolve()
expect(settled).toBe(false)
ledger.transfer([projection.identity.projectionSemanticsId], 'renderer-reload')
await terminal
expect(settled).toBe(true)
})
it('publishes and settles transformed source accounting with no display text', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const projection = ledger.commit(
reserve(ledger, {
data: '',
sequenceEnd: 9,
rawLength: 9,
transformed: true
})
)
ledger.publishPrefix([projection.identity.projectionSemanticsId], 0, 9)
expect(ledger.settlePublishedPrefix('pty-1', 9)).toBe(9)
expect(ledger.getDebugSnapshot()).toMatchObject({ settled: 1, records: 0 })
})
it('reclaims a closed PTY cursor so its id can be reused by a new incarnation', () => {
const ledger = new SshPtyLegacyProjectionLedger()
ledger.commit(reserve(ledger))
ledger.closePty('pty-1', 3, 'incarnation-1', 'pty-exit')
const next = reserve(ledger, {
ptyIncarnation: 'incarnation-2',
data: 'next',
sequenceEnd: 4,
rawLength: 4
})
expect(next.semantics.identity).toMatchObject({
ptyIncarnation: 'incarnation-2',
displayStart: 0
})
expect(ledger.getDebugSnapshot()).toMatchObject({ records: 1, cursors: 1 })
})
it('keeps split publication attached to one immutable desktop span', () => {
const settled: unknown[] = []
const ledger = new SshPtyLegacyProjectionLedger({
onSettled: (span) => settled.push(span)
})
const projection = ledger.commit(
reserve(ledger, {
data: 'abcd',
rawLength: 4,
sequenceEnd: 4,
source: source(0, 4)
})
)
const id = projection.identity.projectionSemanticsId
expect(ledger.hasUnpublished(id)).toBe(true)
ledger.publishPrefix([id], 2, 2)
expect(ledger.hasUnpublished(id)).toBe(true)
ledger.settlePublishedPrefix('pty-1', 2)
expect(settled).toEqual([])
ledger.publishPrefix([id], 2, 2)
expect(ledger.hasUnpublished(id)).toBe(false)
ledger.settlePublishedPrefix('pty-1', 2)
expect(settled).toEqual([
expect.objectContaining({
spanId: 'span-0',
projectionSemanticsId: id,
sourceStartSu: 0,
sourceEndSu: 4,
displayStart: 0,
displayEnd: 4
})
])
})
it('preserves scanner and display facts after transfer and delivery-token replacement', () => {
const transferred: unknown[] = []
const ledger = new SshPtyLegacyProjectionLedger({
onTransferred: (span) => transferred.push(span)
})
const partial = ledger.commit(
reserve(ledger, {
data: '\x1b[?20',
rawLength: 5,
sequenceEnd: 5,
source: source(0, 5)
})
)
ledger.transfer([partial.identity.projectionSemanticsId], 'renderer-reload')
const continuation = reserve(ledger, {
data: '31h',
rawLength: 3,
sequenceEnd: 8,
source: source(5, 8, 'token-2')
})
expect(continuation.semantics.identity.displayStart).toBe(5)
expect(continuation.semantics.beforeScanner.tail).toBe('\x1b[?20')
expect(continuation.semantics.decision).toBe('subscribed')
expect(transferred).toEqual([
expect.objectContaining({
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 5
})
])
})
it('leaves a projection intact when its terminal transition cannot commit', () => {
const ledger = new SshPtyLegacyProjectionLedger({
onTransferred: () => {
throw new Error('replacement unavailable')
}
})
const projection = ledger.commit(reserve(ledger, { source: source(0, 3) }))
expect(() =>
ledger.transfer([projection.identity.projectionSemanticsId], 'renderer-reload')
).toThrow('replacement unavailable')
expect(ledger.getDebugSnapshot()).toMatchObject({
transferred: 0,
records: 1
})
})
})
+318
View File
@@ -0,0 +1,318 @@
import { scanMode2031ReplyDecision } from '../../shared/terminal-color-scheme-protocol'
import {
closeProjectionPty,
getOrCreateProjectionCursor,
projectionDebugSnapshot,
projectionError,
reclaimProjectionRecord,
resetProjectionCursorForGap,
requireProjectionRecord,
rollbackCommittedProjectionRecord,
scannerSnapshot,
type DesktopProjectionSpan,
type LegacySshProjectionDebugSnapshot,
type LegacySshProjectionReservation,
type LegacySshProjectionSemantics,
type ProjectionRecord,
type PtyProjectionCursor
} from './ssh-pty-legacy-projection-record'
import {
projectionHasOpen,
resolveProjectionTerminality,
SshPtyProjectionTerminality,
unpublishedProjectionIds
} from './ssh-pty-projection-terminality'
import {
hasUnpublishedLegacyProjection,
publishLegacyProjectionPrefix,
settlePublishedLegacyProjectionPrefix
} from './ssh-pty-legacy-projection-publication'
export type {
DesktopProjectionSpan,
LegacySshProjectionIdentity,
LegacySshProjectionReservation,
LegacySshProjectionSemantics
} from './ssh-pty-legacy-projection-record'
export type SshPtyLegacyProjectionLedgerOptions = {
onSettled?: (span: DesktopProjectionSpan, reason: string) => void
onTransferred?: (span: DesktopProjectionSpan, reason: string) => void
}
export class SshPtyLegacyProjectionLedger {
private nextId = 1
private readonly records = new Map<string, ProjectionRecord>()
private readonly cursorByPty = new Map<string, PtyProjectionCursor>()
private readonly idsByPty = new Map<string, string[]>()
private readonly terminality = new SshPtyProjectionTerminality()
private settledCount = 0
private transferredCount = 0
private rolledBackCount = 0
constructor(private readonly options: SshPtyLegacyProjectionLedgerOptions = {}) {}
reserve(args: {
ptyId: string
providerGeneration: number
ptyIncarnation: string
data: string
sequenceEnd: number
rawLength: number
transformed: boolean
source?: Readonly<{
relayPtyId?: string
spanId: string
clientGeneration: number
ownerGeneration: number
deliveryToken: string
sourceStartSu: number
sourceEndSu: number
}>
}): LegacySshProjectionReservation {
const cursor = getOrCreateProjectionCursor(this.cursorByPty, args, (generation) =>
this.transferGeneration(generation, 'provider-generation-replaced')
)
if (
cursor.providerGeneration !== args.providerGeneration ||
cursor.ptyIncarnation !== args.ptyIncarnation
) {
throw projectionError('ssh_projection_stale_generation')
}
const scan = scanMode2031ReplyDecision(cursor.scanner, args.data)
const projectionSemanticsId = `ssh-projection:${args.providerGeneration}:${this.nextId++}`
const identity = Object.freeze({
projectionSemanticsId,
ptyId: args.ptyId,
providerGeneration: args.providerGeneration,
ptyIncarnation: args.ptyIncarnation,
displayStart: cursor.displayEnd,
displayEnd: cursor.displayEnd + args.data.length,
sequenceEnd: args.sequenceEnd,
rawLength: args.rawLength,
transformed: args.transformed
})
const semantics = Object.freeze({
identity,
...(args.source
? {
desktopSpan: Object.freeze({
...args.source,
id: args.source.relayPtyId ?? args.ptyId,
projectionSemanticsId,
providerGeneration: args.providerGeneration,
ptyIncarnation: args.ptyIncarnation,
displayStart: identity.displayStart,
displayEnd: identity.displayEnd,
splittable: !args.transformed,
transform: Object.freeze({
transformed: args.transformed,
rawLengthSu: args.rawLength,
scalarSafe: !args.transformed
})
})
}
: {}),
beforeScanner: scannerSnapshot(cursor.scanner),
afterScanner: scannerSnapshot(scan.state),
decision: scan.decision
})
this.records.set(projectionSemanticsId, {
semantics,
state: 'reserved',
publishedDisplay: 0,
publishedAccounting: 0,
settledAccounting: 0
})
return Object.freeze({ semantics })
}
commit(reservation: LegacySshProjectionReservation): LegacySshProjectionSemantics {
const record = requireProjectionRecord(
this.records,
reservation.semantics.identity.projectionSemanticsId
)
if (record.state !== 'reserved') {
throw projectionError('ssh_projection_commit_invalid')
}
const { identity, afterScanner } = record.semantics
const cursor = this.cursorByPty.get(identity.ptyId)
if (
!cursor ||
cursor.providerGeneration !== identity.providerGeneration ||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
cursor.displayEnd !== identity.displayStart
) {
this.records.delete(identity.projectionSemanticsId)
throw projectionError('ssh_projection_commit_stale')
}
cursor.displayEnd = identity.displayEnd
cursor.scanner = { ...afterScanner }
record.state = 'committed'
const ids = this.idsByPty.get(identity.ptyId) ?? []
ids.push(identity.projectionSemanticsId)
this.idsByPty.set(identity.ptyId, ids)
return record.semantics
}
rollback(reservation: LegacySshProjectionReservation): boolean {
const id = reservation.semantics.identity.projectionSemanticsId
const record = this.records.get(id)
if (!record || record.state !== 'reserved') {
return false
}
this.records.delete(id)
this.rolledBackCount++
return true
}
rollbackCommitted(reservation: LegacySshProjectionReservation): boolean {
const ptyId = rollbackCommittedProjectionRecord(
this.records,
this.idsByPty,
this.cursorByPty,
reservation
)
if (!ptyId) {
return false
}
this.rolledBackCount++
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
return true
}
publishPrefix(ids: readonly string[], displayChars: number, accountingChars: number): void {
publishLegacyProjectionPrefix(this.records, ids, displayChars, accountingChars)
}
settlePublishedPrefix(ptyId: string, accountingChars: number): number {
const result = settlePublishedLegacyProjectionPrefix(
this.records,
this.idsByPty,
ptyId,
accountingChars,
this.options.onSettled
)
this.settledCount += result.completed
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
return result.settled
}
transfer(ids: readonly string[], reason: string): number {
let transferred = 0
const touchedPtys = new Set<string>()
for (const id of ids.slice()) {
const record = this.records.get(id)
if (!record || record.state === 'reserved') {
continue
}
const ptyId = record.semantics.identity.ptyId
if (record.semantics.desktopSpan) {
this.options.onTransferred?.(record.semantics.desktopSpan, reason)
}
this.transferredCount++
reclaimProjectionRecord(this.records, this.idsByPty, id, ptyId)
touchedPtys.add(ptyId)
transferred++
}
for (const ptyId of touchedPtys) {
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
}
return transferred
}
whenPtyTerminal(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string
): Promise<void> {
return this.terminality.whenTerminal(
ptyId,
providerGeneration,
ptyIncarnation,
projectionHasOpen(this.records, this.idsByPty, ptyId)
)
}
transferGeneration(providerGeneration: number, reason: string): number {
const ids: string[] = []
for (const [id, record] of this.records) {
if (record.semantics.identity.providerGeneration === providerGeneration) {
ids.push(id)
}
}
return this.transfer(ids, reason)
}
transferPty(ptyId: string, reason: string): number {
return this.transfer(this.idsByPty.get(ptyId) ?? [], reason)
}
transferUnpublishedPty(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
reason: string
): number {
const ids = unpublishedProjectionIds(
this.records,
this.idsByPty.get(ptyId) ?? [],
providerGeneration,
ptyIncarnation
)
return this.transfer(ids, reason)
}
closePty(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
reason: string
): void {
closeProjectionPty(this.cursorByPty, ptyId, providerGeneration, ptyIncarnation, () => {
this.transferPty(ptyId, reason)
this.idsByPty.delete(ptyId)
})
}
closeGeneration(providerGeneration: number, reason: string): void {
for (const [id, record] of this.records) {
if (record.semantics.identity.providerGeneration !== providerGeneration) {
continue
}
if (record.state === 'reserved') {
this.records.delete(id)
this.rolledBackCount++
} else {
this.transfer([id], reason)
}
}
for (const [ptyId, cursor] of this.cursorByPty) {
if (cursor.providerGeneration === providerGeneration) {
this.cursorByPty.delete(ptyId)
this.idsByPty.delete(ptyId)
}
}
this.terminality.closeGeneration(providerGeneration)
}
resetForGap(ptyId: string): void {
resetProjectionCursorForGap(this.cursorByPty, ptyId)
}
get(id: string): LegacySshProjectionSemantics | undefined {
return this.records.get(id)?.semantics
}
hasUnpublished(id: string): boolean {
return hasUnpublishedLegacyProjection(this.records, id)
}
getDebugSnapshot(): LegacySshProjectionDebugSnapshot {
return projectionDebugSnapshot(this.records, this.cursorByPty, {
settled: this.settledCount,
transferred: this.transferredCount,
rolledBack: this.rolledBackCount
})
}
}
@@ -0,0 +1,35 @@
export type SshPtyModelAdmissionKey = Readonly<{
ptyId: string
providerGeneration: number
}>
export type SshPtyModelAdmissionReceipt = Readonly<{
ptyId: string
providerGeneration: number
sequence: number
}>
export type SshPtyModelAdmissionDebugSnapshot = Readonly<{
sourceUnits: number
bytes: number
pressureFrames: number
pressureBytes: number
pausedPtys: number
migratingPtys: number
}>
export type SshPtyModelAdmissionOptions = {
perPtyHighSourceUnits?: number
perPtyHighBytes?: number
perPtyLowSourceUnits?: number
perPtyLowBytes?: number
globalHighSourceUnits?: number
globalHighBytes?: number
globalLowSourceUnits?: number
globalLowBytes?: number
pressureMaxFrames?: number
pressureMaxBytes?: number
pauseProvider?: (key: SshPtyModelAdmissionKey) => boolean
resumeProvider?: (key: SshPtyModelAdmissionKey) => void
closeProvider?: (providerGeneration: number, reason: string) => void
}
@@ -0,0 +1,151 @@
import type {
SshPtyModelAdmissionKey,
SshPtyModelAdmissionReceipt
} from './ssh-pty-model-admission-contract'
import type { SshPtyModelAdmissionLimits } from './ssh-pty-model-admission-limits'
export type AdmissionCharge = { sourceUnits: number; bytes: number }
export type AdmissionEntry = {
key: SshPtyModelAdmissionKey
charge: AdmissionCharge
run: () => { sequence: number; completion: Promise<void> }
resolve: (receipt: SshPtyModelAdmissionReceipt) => void
reject: (error: Error) => void
state: 'queued' | 'running' | 'pressure' | 'settled'
}
export type PtyUsage = AdmissionCharge & {
queued: AdmissionEntry[]
running: AdmissionEntry | null
}
export function admissionError(code: string): Error {
return Object.assign(new Error(code), { code })
}
export function retainedBytes(data: string): number {
return Math.max(Buffer.byteLength(data, 'utf8'), 2 * data.length) + 128
}
export function admissionKeyId(key: SshPtyModelAdmissionKey): string {
return `${key.providerGeneration}\0${key.ptyId}`
}
export function canReserveAdmission(args: {
key: SshPtyModelAdmissionKey
charge: AdmissionCharge
limits: SshPtyModelAdmissionLimits
usageByPty: ReadonlyMap<string, PtyUsage>
closingGenerations: ReadonlySet<number>
globalSourceUnits: number
globalBytes: number
}): boolean {
if (args.closingGenerations.has(args.key.providerGeneration)) {
return false
}
const usage = args.usageByPty.get(admissionKeyId(args.key))
return (
(usage?.sourceUnits ?? 0) + args.charge.sourceUnits <= args.limits.perPtyHighSourceUnits &&
(usage?.bytes ?? 0) + args.charge.bytes <= args.limits.perPtyHighBytes &&
args.globalSourceUnits + args.charge.sourceUnits <= args.limits.globalHighSourceUnits &&
args.globalBytes + args.charge.bytes <= args.limits.globalHighBytes
)
}
export function pressureHasAdmissionKey(
entries: readonly AdmissionEntry[],
key: SshPtyModelAdmissionKey
): boolean {
const id = admissionKeyId(key)
return entries.some((entry) => admissionKeyId(entry.key) === id)
}
export function takePressureEntriesForGeneration(
entries: AdmissionEntry[],
providerGeneration: number
): AdmissionEntry[] {
const removed: AdmissionEntry[] = []
for (let index = entries.length - 1; index >= 0; index--) {
if (entries[index]!.key.providerGeneration === providerGeneration) {
removed.unshift(entries.splice(index, 1)[0]!)
}
}
return removed
}
export function cancelAdmissionGeneration(args: {
pressure: AdmissionEntry[]
usageByPty: Map<string, PtyUsage>
idleWaiters: Map<string, Set<() => void>>
providerGeneration: number
error: Error
release: (key: SshPtyModelAdmissionKey, charge: AdmissionCharge) => void
}): number {
let pressureBytes = 0
for (const entry of takePressureEntriesForGeneration(args.pressure, args.providerGeneration)) {
pressureBytes += entry.charge.bytes
entry.state = 'settled'
entry.reject(args.error)
}
for (const [id, usage] of args.usageByPty) {
const canceled = usage.queued.filter(
(entry) => entry.key.providerGeneration === args.providerGeneration
)
usage.queued = usage.queued.filter(
(entry) => entry.key.providerGeneration !== args.providerGeneration
)
if (usage.running?.key.providerGeneration === args.providerGeneration) {
canceled.push(usage.running)
usage.running = null
}
for (const entry of canceled) {
if (entry.state === 'settled') {
continue
}
entry.state = 'settled'
args.release(entry.key, entry.charge)
entry.reject(args.error)
}
if (!usage.running && usage.queued.length === 0 && usage.sourceUnits === 0) {
args.usageByPty.delete(id)
}
resolveAdmissionIdleWaiters(args.usageByPty, args.pressure, args.idleWaiters, id)
}
return pressureBytes
}
export function takePausedGeneration(
paused: Map<string, SshPtyModelAdmissionKey>,
providerGeneration: number
): SshPtyModelAdmissionKey[] {
const removed: SshPtyModelAdmissionKey[] = []
for (const [id, key] of paused) {
if (key.providerGeneration === providerGeneration) {
paused.delete(id)
removed.push(key)
}
}
return removed
}
export function resolveAdmissionIdleWaiters(
usageByPty: ReadonlyMap<string, PtyUsage>,
pressure: readonly AdmissionEntry[],
waitersById: Map<string, Set<() => void>>,
id: string
): void {
const usage = usageByPty.get(id)
const stillPressured = pressure.some((entry) => admissionKeyId(entry.key) === id)
if (usage?.running || (usage?.queued.length ?? 0) > 0 || stillPressured) {
return
}
const waiters = waitersById.get(id)
if (!waiters) {
return
}
waitersById.delete(id)
for (const resolve of waiters) {
resolve()
}
}
@@ -0,0 +1,38 @@
import type { SshPtyModelAdmissionOptions } from './ssh-pty-model-admission-contract'
export type SshPtyModelAdmissionLimits = Readonly<{
perPtyHighSourceUnits: number
perPtyHighBytes: number
perPtyLowSourceUnits: number
perPtyLowBytes: number
globalHighSourceUnits: number
globalHighBytes: number
globalLowSourceUnits: number
globalLowBytes: number
pressureMaxFrames: number
pressureMaxBytes: number
}>
const DEFAULT_PER_PTY_HIGH_SOURCE_UNITS = 256 * 1024
const DEFAULT_PER_PTY_HIGH_BYTES = 2 * 1024 * 1024
const DEFAULT_GLOBAL_HIGH_SOURCE_UNITS = 50 * DEFAULT_PER_PTY_HIGH_SOURCE_UNITS
const DEFAULT_GLOBAL_HIGH_BYTES = 64 * 1024 * 1024
export function resolveSshPtyModelAdmissionLimits(
options: SshPtyModelAdmissionOptions
): SshPtyModelAdmissionLimits {
const perPtyHighSourceUnits = options.perPtyHighSourceUnits ?? DEFAULT_PER_PTY_HIGH_SOURCE_UNITS
const perPtyHighBytes = options.perPtyHighBytes ?? DEFAULT_PER_PTY_HIGH_BYTES
return {
perPtyHighSourceUnits,
perPtyHighBytes,
perPtyLowSourceUnits: options.perPtyLowSourceUnits ?? Math.floor(perPtyHighSourceUnits / 2),
perPtyLowBytes: options.perPtyLowBytes ?? Math.floor(perPtyHighBytes / 2),
globalHighSourceUnits: options.globalHighSourceUnits ?? DEFAULT_GLOBAL_HIGH_SOURCE_UNITS,
globalHighBytes: options.globalHighBytes ?? DEFAULT_GLOBAL_HIGH_BYTES,
globalLowSourceUnits: options.globalLowSourceUnits ?? 8 * 1024 * 1024,
globalLowBytes: options.globalLowBytes ?? 48 * 1024 * 1024,
pressureMaxFrames: options.pressureMaxFrames ?? 64,
pressureMaxBytes: options.pressureMaxBytes ?? 1024 * 1024
}
}
@@ -0,0 +1,91 @@
import type { SshPtyModelAdmissionKey } from './ssh-pty-model-admission-contract'
import type { AdmissionCharge, AdmissionEntry, PtyUsage } from './ssh-pty-model-admission-entry'
import { admissionError, admissionKeyId } from './ssh-pty-model-admission-entry'
import type { SshPtyModelAdmissionPressure } from './ssh-pty-model-admission-pressure'
export function beginSshPtyModelAdmissionMigration(args: {
key: SshPtyModelAdmissionKey
migratingPtys: Set<string>
pressure: SshPtyModelAdmissionPressure
usageByPty: Map<string, PtyUsage>
release: (key: SshPtyModelAdmissionKey, charge: AdmissionCharge) => void
cleanup: (id: string, usage: PtyUsage) => void
}): void {
const id = admissionKeyId(args.key)
if (args.migratingPtys.has(id)) {
return
}
args.migratingPtys.add(id)
const error = admissionError('ssh_model_migration_queued_canceled')
args.pressure.cancelQueuedPty(args.key, error)
const usage = args.usageByPty.get(id)
if (!usage) {
return
}
const queued = usage.queued
usage.queued = []
for (const entry of queued) {
cancelQueuedEntry(entry, error, args.release)
}
args.cleanup(id, usage)
}
export function closeSshPtyModelAdmissionMigrations(
migratingPtys: Set<string>,
providerGeneration: number
): void {
const prefix = `${providerGeneration}\0`
for (const id of migratingPtys) {
if (id.startsWith(prefix)) {
migratingPtys.delete(id)
}
}
}
export function settleSshPtyModelAdmissionFailure(args: {
id: string
usage: PtyUsage
entry: AdmissionEntry
error: Error
migratingPtys: ReadonlySet<string>
closingGenerations: Set<number>
release: (key: SshPtyModelAdmissionKey, charge: AdmissionCharge) => void
closeGeneration: (providerGeneration: number) => void
cleanup: (id: string, usage: PtyUsage) => void
}): void {
if (args.entry.state !== 'running' || args.usage.running !== args.entry) {
return
}
const migrationOwnsFailure = args.migratingPtys.has(args.id)
if (!migrationOwnsFailure) {
args.closingGenerations.add(args.entry.key.providerGeneration)
}
args.usage.running = null
args.entry.state = 'settled'
args.release(args.entry.key, args.entry.charge)
args.entry.reject(migrationOwnsFailure ? migrationCompletionError(args.error) : args.error)
if (!migrationOwnsFailure) {
args.closeGeneration(args.entry.key.providerGeneration)
}
args.cleanup(args.id, args.usage)
}
function migrationCompletionError(cause: Error): Error {
return Object.assign(new Error(cause.message), {
code: 'ssh_model_migration_completion_failed',
cause
})
}
function cancelQueuedEntry(
entry: AdmissionEntry,
error: Error,
release: (key: SshPtyModelAdmissionKey, charge: AdmissionCharge) => void
): void {
if (entry.state === 'settled') {
return
}
entry.state = 'settled'
release(entry.key, entry.charge)
entry.reject(error)
}
@@ -0,0 +1,153 @@
import type { SshPtyModelAdmissionKey } from './ssh-pty-model-admission-contract'
import {
admissionKeyId,
pressureHasAdmissionKey,
takePausedGeneration,
type AdmissionEntry,
type PtyUsage
} from './ssh-pty-model-admission-entry'
import type { SshPtyModelAdmissionLimits } from './ssh-pty-model-admission-limits'
type PressureOptions = {
limits: SshPtyModelAdmissionLimits
pauseProvider: (key: SshPtyModelAdmissionKey) => boolean
resumeProvider: (key: SshPtyModelAdmissionKey) => void
}
type PromotionOptions = {
usageByPty: ReadonlyMap<string, PtyUsage>
disposed: boolean
canReserve: (entry: AdmissionEntry) => boolean
reserve: (entry: AdmissionEntry) => void
isBelowGlobalLowWatermark: () => boolean
}
export class SshPtyModelAdmissionPressure {
private readonly entries: AdmissionEntry[] = []
private readonly pausedKeys = new Map<string, SshPtyModelAdmissionKey>()
private retainedBytes = 0
constructor(private readonly options: PressureOptions) {}
get values(): readonly AdmissionEntry[] {
return this.entries
}
get frameCount(): number {
return this.entries.length
}
get bytes(): number {
return this.retainedBytes
}
get pausedPtyCount(): number {
return this.pausedKeys.size
}
get pausedProviderGenerations(): ReadonlySet<number> {
return new Set(Array.from(this.pausedKeys.values(), (key) => key.providerGeneration))
}
has(key: SshPtyModelAdmissionKey): boolean {
return pressureHasAdmissionKey(this.entries, key)
}
admit(entry: AdmissionEntry): boolean {
const id = admissionKeyId(entry.key)
const paused = this.pausedKeys.has(id) || this.options.pauseProvider(entry.key)
if (paused) {
this.pausedKeys.set(id, entry.key)
}
if (
!paused ||
this.entries.length >= this.options.limits.pressureMaxFrames ||
this.retainedBytes + entry.charge.bytes > this.options.limits.pressureMaxBytes
) {
return false
}
entry.state = 'pressure'
this.entries.push(entry)
this.retainedBytes += entry.charge.bytes
return true
}
cancelGeneration(
providerGeneration: number,
cancelPressureAndReserved: (entries: AdmissionEntry[]) => number
): void {
this.retainedBytes -= cancelPressureAndReserved(this.entries)
for (const key of takePausedGeneration(this.pausedKeys, providerGeneration)) {
this.resume(key)
}
}
cancelPty(key: SshPtyModelAdmissionKey, error: Error, cancelReserved: () => void): void {
this.cancelQueuedPty(key, error)
cancelReserved()
}
cancelQueuedPty(key: SshPtyModelAdmissionKey, error: Error): void {
const id = admissionKeyId(key)
const canceled: AdmissionEntry[] = []
for (let index = this.entries.length - 1; index >= 0; index--) {
if (admissionKeyId(this.entries[index]!.key) === id) {
canceled.push(this.entries.splice(index, 1)[0]!)
}
}
this.rejectPressureEntries(canceled, error)
const paused = this.pausedKeys.get(id)
if (paused) {
this.pausedKeys.delete(id)
this.resume(paused)
}
}
promoteAndResume(options: PromotionOptions): void {
if (options.disposed) {
return
}
for (let index = 0; index < this.entries.length; ) {
const entry = this.entries[index]!
const hasEarlierEntryForPty = this.entries
.slice(0, index)
.some((earlier) => admissionKeyId(earlier.key) === admissionKeyId(entry.key))
if (hasEarlierEntryForPty || !options.canReserve(entry)) {
index++
continue
}
this.entries.splice(index, 1)
this.retainedBytes -= entry.charge.bytes
options.reserve(entry)
}
if (!options.isBelowGlobalLowWatermark()) {
return
}
for (const [id, key] of this.pausedKeys) {
const usage = options.usageByPty.get(id)
if (
this.has(key) ||
(usage?.sourceUnits ?? 0) > this.options.limits.perPtyLowSourceUnits ||
(usage?.bytes ?? 0) > this.options.limits.perPtyLowBytes
) {
continue
}
this.pausedKeys.delete(id)
this.resume(key)
}
}
private rejectPressureEntries(entries: readonly AdmissionEntry[], error: Error): void {
for (const entry of entries) {
this.retainedBytes -= entry.charge.bytes
entry.state = 'settled'
entry.reject(error)
}
}
private resume(key: SshPtyModelAdmissionKey): void {
try {
this.options.resumeProvider(key)
} catch {}
}
}
@@ -0,0 +1,18 @@
import type { SshPtyModelAdmissionDebugSnapshot } from './ssh-pty-model-admission-contract'
import type { SshPtyModelAdmissionPressure } from './ssh-pty-model-admission-pressure'
export function sshPtyModelAdmissionSnapshot(
sourceUnits: number,
bytes: number,
pressure: SshPtyModelAdmissionPressure,
migratingPtys: ReadonlySet<string>
): SshPtyModelAdmissionDebugSnapshot {
return {
sourceUnits,
bytes,
pressureFrames: pressure.frameCount,
pressureBytes: pressure.bytes,
pausedPtys: pressure.pausedPtyCount,
migratingPtys: migratingPtys.size
}
}
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest'
import { SshPtyModelAdmission } from './ssh-pty-model-admission'
function deferred() {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
function accept(admission: SshPtyModelAdmission, completion: Promise<void>) {
return admission.accept({ ptyId: 'pty-1', providerGeneration: 7 }, 'data', 4, () => ({
sequence: 4,
completion
}))
}
describe('SshPtyModelAdmission', () => {
it('freezes migration while retaining the running raw completion', async () => {
const runningCompletion = deferred()
const admission = new SshPtyModelAdmission()
const running = accept(admission, runningCompletion.promise)
const queued = accept(admission, Promise.resolve())
admission.beginMigration({ ptyId: 'pty-1', providerGeneration: 7 })
await expect(queued).rejects.toThrow('ssh_model_migration_queued_canceled')
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 4 })
await expect(
admission.accept({ ptyId: 'pty-1', providerGeneration: 7 }, 'late', 4, () => ({
sequence: 8,
completion: Promise.resolve()
}))
).rejects.toThrow('ssh_model_admission_migrating')
runningCompletion.resolve()
await expect(running).resolves.toMatchObject({ sequence: 4 })
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
})
it('cancels a never-settling running entry when its generation closes', async () => {
const admission = new SshPtyModelAdmission()
const receipt = accept(admission, new Promise<void>(() => {}))
const idle = admission.whenIdle({ ptyId: 'pty-1', providerGeneration: 7 })
admission.closeGeneration(7, 'provider-closed')
await expect(receipt).rejects.toThrow('provider-closed')
await expect(idle).resolves.toBeUndefined()
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
})
it('cancels a never-settling running entry on disposal', async () => {
const admission = new SshPtyModelAdmission()
const receipt = accept(admission, new Promise<void>(() => {}))
const idle = admission.whenIdle({ ptyId: 'pty-1', providerGeneration: 7 })
admission.dispose()
await expect(receipt).rejects.toThrow('ssh_model_admission_disposed')
await expect(idle).resolves.toBeUndefined()
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
})
it('keeps non-migrating callback failure generation-fatal across sibling PTYs', async () => {
const admission = new SshPtyModelAdmission()
const failedCompletion = deferred()
const siblingCompletion = deferred()
const failed = accept(admission, failedCompletion.promise)
const sibling = admission.accept({ ptyId: 'pty-2', providerGeneration: 7 }, 'data', 4, () => ({
sequence: 4,
completion: siblingCompletion.promise
}))
failedCompletion.reject(new Error('emulator failed'))
await expect(failed).rejects.toThrow('emulator failed')
await expect(sibling).rejects.toThrow('ssh_model_admission_completion_failed')
await expect(
admission.accept({ ptyId: 'pty-3', providerGeneration: 7 }, 'data', 4, () => ({
sequence: 4,
completion: Promise.resolve()
}))
).rejects.toThrow('ssh_model_admission_generation_closed')
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
siblingCompletion.resolve()
await Promise.resolve()
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
})
it('resumes every paused provider generation exactly once on disposal', async () => {
const resumeProvider = vi.fn()
const admission = new SshPtyModelAdmission({
perPtyHighSourceUnits: 4,
perPtyHighBytes: 1024,
globalHighSourceUnits: 4,
globalHighBytes: 1024,
pressureMaxFrames: 1,
pressureMaxBytes: 1024,
pauseProvider: () => true,
resumeProvider
})
const running = accept(admission, new Promise<void>(() => {}))
const pressured = accept(admission, Promise.resolve())
const rejected = admission.accept({ ptyId: 'pty-2', providerGeneration: 8 }, 'data', 4, () => ({
sequence: 4,
completion: Promise.resolve()
}))
await expect(rejected).rejects.toThrow('ssh_model_admission_pressure_exhausted')
admission.dispose()
admission.dispose()
await expect(running).rejects.toThrow('ssh_model_admission_disposed')
await expect(pressured).rejects.toThrow('ssh_model_admission_disposed')
expect(resumeProvider.mock.calls).toEqual([
[{ ptyId: 'pty-1', providerGeneration: 7 }],
[{ ptyId: 'pty-2', providerGeneration: 8 }]
])
})
it.each(['resolve', 'reject'] as const)(
'ignores a late completion %s after cancellation',
async (settle) => {
const completion = deferred()
const admission = new SshPtyModelAdmission()
const onResolve = vi.fn()
const onReject = vi.fn()
const receipt = accept(admission, completion.promise)
const observed = receipt.then(onResolve, onReject)
admission.closeGeneration(7, 'provider-closed')
await observed
if (settle === 'resolve') {
completion.resolve()
} else {
completion.reject(new Error('late emulator failure'))
}
await Promise.resolve()
expect(onResolve).not.toHaveBeenCalled()
expect(onReject).toHaveBeenCalledTimes(1)
expect(onReject.mock.calls[0]?.[0]).toMatchObject({ message: 'provider-closed' })
expect(admission.getDebugSnapshot()).toMatchObject({ sourceUnits: 0, bytes: 0 })
}
)
})
+315
View File
@@ -0,0 +1,315 @@
import type {
SshPtyModelAdmissionKey,
SshPtyModelAdmissionOptions,
SshPtyModelAdmissionReceipt
} from './ssh-pty-model-admission-contract'
import {
admissionError,
admissionKeyId,
canReserveAdmission,
cancelAdmissionGeneration,
retainedBytes,
resolveAdmissionIdleWaiters,
type AdmissionCharge,
type AdmissionEntry,
type PtyUsage
} from './ssh-pty-model-admission-entry'
import { resolveSshPtyModelAdmissionLimits } from './ssh-pty-model-admission-limits'
import * as modelAdmissionMigration from './ssh-pty-model-admission-migration'
import { SshPtyModelAdmissionPressure } from './ssh-pty-model-admission-pressure'
import { sshPtyModelAdmissionSnapshot } from './ssh-pty-model-admission-snapshot'
export type * from './ssh-pty-model-admission-contract'
export class SshPtyModelAdmission {
private readonly limits: ReturnType<typeof resolveSshPtyModelAdmissionLimits>
private readonly closeProvider: (providerGeneration: number, reason: string) => void
private readonly usageByPty = new Map<string, PtyUsage>()
private readonly pressure: SshPtyModelAdmissionPressure
private readonly idleWaiters = new Map<string, Set<() => void>>()
private readonly closingGenerations = new Set<number>()
private readonly migratingPtys = new Set<string>()
private globalSourceUnits = 0
private globalBytes = 0
private disposed = false
constructor(options: SshPtyModelAdmissionOptions = {}) {
this.limits = resolveSshPtyModelAdmissionLimits(options)
this.pressure = new SshPtyModelAdmissionPressure({
limits: this.limits,
pauseProvider: options.pauseProvider ?? (() => false),
resumeProvider: options.resumeProvider ?? (() => {})
})
this.closeProvider = options.closeProvider ?? (() => {})
}
accept(
key: SshPtyModelAdmissionKey,
data: string,
sourceUnits: number,
run: () => { sequence: number; completion: Promise<void> }
): Promise<SshPtyModelAdmissionReceipt> {
if (this.disposed) {
return Promise.reject(admissionError('ssh_model_admission_disposed'))
}
if (this.closingGenerations.has(key.providerGeneration)) {
return Promise.reject(admissionError('ssh_model_admission_generation_closed'))
}
if (this.migratingPtys.has(admissionKeyId(key))) {
return Promise.reject(admissionError('ssh_model_admission_migrating'))
}
const charge = { sourceUnits, bytes: retainedBytes(data) }
return new Promise((resolve, reject) => {
const entry: AdmissionEntry = {
key: { ...key },
charge,
run,
resolve,
reject,
state: 'queued'
}
if (this.canReserve(key, charge) && !this.pressure.has(key)) {
this.reserveAndQueue(entry)
return
}
if (!this.pressure.admit(entry)) {
entry.reject(admissionError('ssh_model_admission_pressure_exhausted'))
this.closeProvider(entry.key.providerGeneration, 'model-admission-pressure')
}
})
}
closeGeneration(providerGeneration: number, reason = 'provider-generation-closed'): void {
this.closingGenerations.add(providerGeneration)
const error = admissionError(reason)
this.pressure.cancelGeneration(providerGeneration, (pressure) =>
cancelAdmissionGeneration({
pressure,
usageByPty: this.usageByPty,
idleWaiters: this.idleWaiters,
providerGeneration,
error,
release: (key, charge) => this.release(key, charge)
})
)
modelAdmissionMigration.closeSshPtyModelAdmissionMigrations(
this.migratingPtys,
providerGeneration
)
const generationPrefix = `${providerGeneration}\0`
for (const id of this.idleWaiters.keys()) {
if (id.startsWith(generationPrefix)) {
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure.values, this.idleWaiters, id)
}
}
}
beginMigration(key: SshPtyModelAdmissionKey): void {
modelAdmissionMigration.beginSshPtyModelAdmissionMigration({
key,
migratingPtys: this.migratingPtys,
pressure: this.pressure,
usageByPty: this.usageByPty,
release: (entryKey, charge) => this.release(entryKey, charge),
cleanup: (id, usage) => this.cleanupUsage(id, usage)
})
const id = admissionKeyId(key)
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure.values, this.idleWaiters, id)
}
cancelPty(key: SshPtyModelAdmissionKey, reason: string): void {
const id = admissionKeyId(key)
const error = admissionError(reason)
this.pressure.cancelPty(key, error, () => {
const usage = this.usageByPty.get(id)
if (usage) {
const canceled = [...usage.queued, ...(usage.running ? [usage.running] : [])]
usage.queued = []
usage.running = null
for (const entry of canceled) {
if (entry.state === 'settled') {
continue
}
entry.state = 'settled'
this.release(entry.key, entry.charge)
entry.reject(error)
}
this.usageByPty.delete(id)
}
})
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure.values, this.idleWaiters, id)
}
whenIdle(key: SshPtyModelAdmissionKey): Promise<void> {
const id = admissionKeyId(key)
const usage = this.usageByPty.get(id)
const hasPressure = this.pressure.has(key)
if ((!usage || (!usage.running && usage.queued.length === 0)) && !hasPressure) {
return Promise.resolve()
}
return new Promise((resolve) => {
const waiters = this.idleWaiters.get(id) ?? new Set<() => void>()
waiters.add(resolve)
this.idleWaiters.set(id, waiters)
})
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
const generations = new Set<number>()
for (const usage of this.usageByPty.values()) {
if (usage.running) {
generations.add(usage.running.key.providerGeneration)
}
for (const entry of usage.queued) {
generations.add(entry.key.providerGeneration)
}
}
for (const entry of this.pressure.values) {
generations.add(entry.key.providerGeneration)
}
for (const generation of this.pressure.pausedProviderGenerations) {
generations.add(generation)
}
for (const generation of generations) {
this.closeGeneration(generation, 'ssh_model_admission_disposed')
}
}
getDebugSnapshot() {
return sshPtyModelAdmissionSnapshot(
this.globalSourceUnits,
this.globalBytes,
this.pressure,
this.migratingPtys
)
}
private canReserve(key: SshPtyModelAdmissionKey, charge: AdmissionCharge): boolean {
if (this.migratingPtys.has(admissionKeyId(key))) {
return false
}
return canReserveAdmission({
key,
charge,
limits: this.limits,
usageByPty: this.usageByPty,
closingGenerations: this.closingGenerations,
globalSourceUnits: this.globalSourceUnits,
globalBytes: this.globalBytes
})
}
private reserveAndQueue(entry: AdmissionEntry): void {
const id = admissionKeyId(entry.key)
let usage = this.usageByPty.get(id)
if (!usage) {
usage = { sourceUnits: 0, bytes: 0, queued: [], running: null }
this.usageByPty.set(id, usage)
}
usage.sourceUnits += entry.charge.sourceUnits
usage.bytes += entry.charge.bytes
this.globalSourceUnits += entry.charge.sourceUnits
this.globalBytes += entry.charge.bytes
entry.state = 'queued'
usage.queued.push(entry)
this.startNext(id, usage)
}
private startNext(id: string, usage: PtyUsage): void {
if (usage.running) {
return
}
const entry = usage.queued.shift()
if (!entry) {
this.promotePressureAndResumeProvider()
return
}
usage.running = entry
entry.state = 'running'
let execution: { sequence: number; completion: Promise<void> }
try {
execution = entry.run()
} catch (error) {
if (this.finishEntry(id, usage, entry)) {
entry.reject(error instanceof Error ? error : new Error(String(error)))
}
return
}
void execution.completion.then(
() => {
if (!this.finishEntry(id, usage, entry)) {
return
}
entry.resolve({
ptyId: entry.key.ptyId,
providerGeneration: entry.key.providerGeneration,
sequence: execution.sequence
})
},
(error) => {
this.failEntry(id, usage, entry, error instanceof Error ? error : new Error(String(error)))
}
)
}
private failEntry(id: string, usage: PtyUsage, entry: AdmissionEntry, error: Error): void {
modelAdmissionMigration.settleSshPtyModelAdmissionFailure({
id,
usage,
entry,
error,
migratingPtys: this.migratingPtys,
closingGenerations: this.closingGenerations,
release: (key, charge) => this.release(key, charge),
closeGeneration: (providerGeneration) =>
this.closeGeneration(providerGeneration, 'ssh_model_admission_completion_failed'),
cleanup: (entryId, entryUsage) => this.cleanupUsage(entryId, entryUsage)
})
}
private finishEntry(id: string, usage: PtyUsage, entry: AdmissionEntry): boolean {
if (entry.state !== 'running' || usage.running !== entry) {
return false
}
usage.running = null
entry.state = 'settled'
this.release(entry.key, entry.charge)
this.cleanupUsage(id, usage)
return true
}
private cleanupUsage(id: string, usage: PtyUsage): void {
this.startNext(id, usage)
if (!usage.running && usage.queued.length === 0 && usage.sourceUnits === 0) {
this.usageByPty.delete(id)
}
resolveAdmissionIdleWaiters(this.usageByPty, this.pressure.values, this.idleWaiters, id)
}
private release(key: SshPtyModelAdmissionKey, charge: AdmissionCharge): void {
const usage = this.usageByPty.get(admissionKeyId(key))
if (usage) {
usage.sourceUnits = Math.max(0, usage.sourceUnits - charge.sourceUnits)
usage.bytes = Math.max(0, usage.bytes - charge.bytes)
}
this.globalSourceUnits = Math.max(0, this.globalSourceUnits - charge.sourceUnits)
this.globalBytes = Math.max(0, this.globalBytes - charge.bytes)
this.promotePressureAndResumeProvider()
}
private promotePressureAndResumeProvider(): void {
this.pressure.promoteAndResume({
usageByPty: this.usageByPty,
disposed: this.disposed,
canReserve: (entry) => this.canReserve(entry.key, entry.charge),
reserve: (entry) => this.reserveAndQueue(entry),
isBelowGlobalLowWatermark: () =>
this.globalSourceUnits <= this.limits.globalLowSourceUnits &&
this.globalBytes <= this.limits.globalLowBytes
})
}
}
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from 'vitest'
import type { RemoteTerminalSourceRangeStreamIdentity } from '../runtime/remote-terminal-source-range-consumer'
import {
SshPtyOutputIntake,
type SshPtyOutputIntakeDependencies,
type SshPtyOutputReceipt
} from './ssh-pty-output-intake'
import type { SshPtySourceCancellationProof } from './ssh-pty-output-intake-contract'
type ExitDeadlineHarness = Readonly<{
intake: SshPtyOutputIntake
dependencies: SshPtyOutputIntakeDependencies
cancelSourceDelivery: ReturnType<typeof vi.fn>
releaseExit: ReturnType<typeof vi.fn>
exits: string[]
}>
const stream: RemoteTerminalSourceRangeStreamIdentity = {
ptyId: 'pty-1',
consumerId: 'remote-1',
streamGeneration: 'stream-1'
}
function createHarness(
cancelSourceDelivery: NonNullable<SshPtyOutputIntakeDependencies['cancelSourceDelivery']>
): ExitDeadlineHarness {
const exits: string[] = []
const releaseExit = vi.fn()
const cancellation = vi.fn(cancelSourceDelivery)
const dependencies: SshPtyOutputIntakeDependencies = {
getModelSequence: () => 0,
acceptModel: (event) => ({ sequence: event.rawLength, completion: Promise.resolve() }),
project: vi.fn(),
prepareExit: vi.fn(() => releaseExit),
finalizeExit: () => exits.push('exit'),
pauseProvider: vi.fn(() => true),
resumeProvider: vi.fn(),
closeProvider: vi.fn(),
cancelSourceDelivery: cancellation
}
return {
intake: new SshPtyOutputIntake(dependencies, {
exitBarrierMs: 10,
exitCancellationProofMs: 100
}),
dependencies,
cancelSourceDelivery: cancellation,
releaseExit,
exits
}
}
async function publishSource(harness: ExitDeadlineHarness): Promise<SshPtyOutputReceipt> {
const remote = harness.intake.getRemoteSourceRangeConsumerHooks()
expect(remote.attach(stream)).toBe(true)
const receipt = await harness.intake.acceptData({
id: 'pty-1',
data: 'aaaa',
providerGeneration: 1,
ptyIncarnation: 'incarnation-1',
rawLength: 4,
transformed: false,
source: {
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
harness.intake.publishProjectionPrefix([receipt.projection.identity.projectionSemanticsId], 4, 4)
return receipt
}
function acceptExit(harness: ExitDeadlineHarness) {
return harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
}
describe('SshPtyOutputExitDeadline', () => {
it('transfers published projections before cancellation proof reclaims their spans', async () => {
vi.useFakeTimers()
try {
const harness = createHarness(async () => ({ sentEndSu: 4, creditedEndSu: 0 }))
const receipt = await publishSource(harness)
const exitResult = acceptExit(harness).then(
() => ({ ok: true as const }),
(error: Error) => ({ ok: false as const, error })
)
await vi.advanceTimersByTimeAsync(10)
expect(await exitResult).toEqual({ ok: true })
expect(harness.cancelSourceDelivery).toHaveBeenCalledOnce()
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
expect(harness.dependencies.prepareExit).toHaveBeenCalledOnce()
expect(harness.releaseExit).toHaveBeenCalledOnce()
expect(harness.exits).toEqual(['exit'])
expect(harness.intake.getDebugSnapshot()).toMatchObject({
projection: { records: 0 },
source: { openedTokens: 0, ptyIdentities: 0 },
exitBarriers: 0
})
await vi.advanceTimersByTimeAsync(0)
expect(harness.exits).toEqual(['exit'])
expect(() =>
harness.intake
.getRemoteSourceRangeConsumerHooks()
.settle(stream, [receipt.projection.desktopSpan!])
).not.toThrow()
} finally {
vi.useRealTimers()
}
})
it('generation close fences a pending cancellation proof from final exit', async () => {
vi.useFakeTimers()
try {
let resolveCancellation!: (proof: SshPtySourceCancellationProof) => void
const cancellation = new Promise<SshPtySourceCancellationProof>((resolve) => {
resolveCancellation = resolve
})
const harness = createHarness(() => cancellation)
await publishSource(harness)
const exitResult = acceptExit(harness).then(
() => ({ ok: true as const }),
(error: Error) => ({ ok: false as const, error })
)
await vi.advanceTimersByTimeAsync(10)
expect(harness.cancelSourceDelivery).toHaveBeenCalledOnce()
harness.intake.closeGeneration(1, 'provider-closed')
const closed = await exitResult
expect(closed.ok).toBe(false)
expect(closed.ok ? '' : closed.error.message).toContain('provider-closed')
resolveCancellation({ sentEndSu: 4, creditedEndSu: 0 })
await vi.advanceTimersByTimeAsync(0)
expect(harness.exits).toEqual([])
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
expect(harness.intake.getDebugSnapshot()).toMatchObject({
projection: { records: 0 },
source: { openedTokens: 0, ptyIdentities: 0 },
exitBarriers: 0
})
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,239 @@
import type { SshPtyLegacyProjectionLedger } from './ssh-pty-legacy-projection'
import type { SshPtyModelAdmission } from './ssh-pty-model-admission'
import type {
SshPtyOutputExitEvent,
SshPtyOutputIntakeDependencies
} from './ssh-pty-output-intake-contract'
import { outputIntakeError, type SshPtyExitBarrier } from './ssh-pty-output-intake-validation'
import type {
SshPtyOutputSourceObligations,
SshPtySourceCancellationProofCommit
} from './ssh-pty-output-source-obligations'
type SshPtyOutputExitDeadlineDependencies = Readonly<{
admission: SshPtyModelAdmission
projections: SshPtyLegacyProjectionLedger
sourceObligations: SshPtyOutputSourceObligations
intake: SshPtyOutputIntakeDependencies
barrierMs?: number
cancellationProofMs?: number
}>
export class SshPtyOutputExitDeadline {
private readonly barriersByGeneration = new Map<number, Set<SshPtyExitBarrier>>()
private readonly preparedExits = new Set<string>()
private readonly preparedExitReleases = new Map<string, () => void>()
private readonly barrierMs: number
private readonly cancellationProofMs: number
constructor(private readonly dependencies: SshPtyOutputExitDeadlineDependencies) {
this.barrierMs = dependencies.barrierMs ?? 30_000
this.cancellationProofMs = dependencies.cancellationProofMs ?? 10_000
}
wait(
event: SshPtyOutputExitEvent,
start: (validateNormalExit: () => void) => Promise<void>
): Promise<void> {
return new Promise((resolve, reject) => {
let timeoutStarted = false
let settled = false
const validateNormalExit = (): void => {
if (timeoutStarted) {
throw outputIntakeError('ssh_exit_delivery_canceled')
}
}
const settle = (result: { ok: true } | { ok: false; error: Error }): void => {
if (settled) {
return
}
settled = true
clearTimeout(barrier.timer)
this.releasePreparedExit(event)
this.remove(event.providerGeneration, barrier)
if (result.ok) {
resolve()
} else {
reject(result.error)
}
}
const barrier: SshPtyExitBarrier = {
timer: setTimeout(() => {
timeoutStarted = true
void this.cancelTimedOutExit(event, barrier).then(
() => settle({ ok: true }),
(error) => {
this.dependencies.intake.closeProvider?.(
event.providerGeneration,
'ssh-exit-cancellation-proof-failed'
)
settle({
ok: false,
error: error instanceof Error ? error : outputIntakeError(String(error))
})
}
)
}, this.barrierMs),
reject: (error) => settle({ ok: false, error })
}
barrier.timer.unref?.()
let barriers = this.barriersByGeneration.get(event.providerGeneration)
if (!barriers) {
barriers = new Set()
this.barriersByGeneration.set(event.providerGeneration, barriers)
}
barriers.add(barrier)
const promise = start(validateNormalExit)
void promise.then(
() => {
if (!timeoutStarted) {
settle({ ok: true })
}
},
(error) => {
if (!timeoutStarted) {
settle({ ok: false, error })
}
}
)
})
}
closeGeneration(providerGeneration: number, error: Error): void {
const barriers = this.barriersByGeneration.get(providerGeneration)
if (barriers) {
for (const barrier of barriers) {
clearTimeout(barrier.timer)
barrier.reject(error)
}
this.barriersByGeneration.delete(providerGeneration)
}
const prefix = `${providerGeneration}\0`
for (const key of this.preparedExits) {
if (key.startsWith(prefix)) {
this.releasePreparedExitKey(key)
this.preparedExits.delete(key)
}
}
}
prepareExitOnce(event: SshPtyOutputExitEvent): void {
const key = this.exitKey(event)
if (this.preparedExits.has(key)) {
return
}
const release = this.dependencies.intake.prepareExit(event)
this.preparedExits.add(key)
if (release) {
this.preparedExitReleases.set(key, release)
}
}
get activeBarriers(): number {
return Array.from(this.barriersByGeneration.values()).reduce(
(total, barriers) => total + barriers.size,
0
)
}
private async cancelTimedOutExit(
event: SshPtyOutputExitEvent,
barrier: SshPtyExitBarrier
): Promise<void> {
const cancel = this.dependencies.intake.cancelSourceDelivery
if (!cancel) {
throw outputIntakeError('ssh_source_cancellation_publisher_unavailable')
}
this.dependencies.admission.cancelPty(
{ ptyId: event.id, providerGeneration: event.providerGeneration },
'ssh_exit_delivery_canceled'
)
this.dependencies.sourceObligations.sealPty(event)
const cancellation = this.dependencies.sourceObligations.requestPtyCancellationProof(
event,
(request) => cancel(event.providerGeneration, request)
)
let commit: SshPtySourceCancellationProofCommit | null
try {
commit = await this.withCancellationProofDeadline(cancellation)
} catch (error) {
if (!this.isActive(event.providerGeneration, barrier)) {
return
}
throw error
}
if (!this.isActive(event.providerGeneration, barrier)) {
return
}
if (!commit) {
throw outputIntakeError('ssh_source_cancellation_identity_unavailable')
}
this.dependencies.projections.transferPty(event.id, 'ssh-exit-delivery-canceled')
this.dependencies.sourceObligations.commitPtyCancellationProof(commit)
this.prepareExitOnce(event)
if (!this.isActive(event.providerGeneration, barrier)) {
return
}
this.dependencies.intake.finalizeExit(event)
if (!this.isActive(event.providerGeneration, barrier)) {
return
}
this.dependencies.projections.closePty(
event.id,
event.providerGeneration,
event.ptyIncarnation,
'ssh-exit-delivery-canceled'
)
}
private isActive(providerGeneration: number, barrier: SshPtyExitBarrier): boolean {
return this.barriersByGeneration.get(providerGeneration)?.has(barrier) ?? false
}
private exitKey(event: SshPtyOutputExitEvent): string {
return `${event.providerGeneration}\0${event.id}\0${event.ptyIncarnation}`
}
private releasePreparedExit(event: SshPtyOutputExitEvent): void {
const key = this.exitKey(event)
this.preparedExits.delete(key)
this.releasePreparedExitKey(key)
}
private releasePreparedExitKey(key: string): void {
const release = this.preparedExitReleases.get(key)
if (!release) {
return
}
this.preparedExitReleases.delete(key)
release()
}
private withCancellationProofDeadline<T>(promise: Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(outputIntakeError('ssh_source_cancellation_proof_timeout')),
this.cancellationProofMs
)
timer.unref?.()
void promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(error) => {
clearTimeout(timer)
reject(error)
}
)
})
}
private remove(providerGeneration: number, barrier: SshPtyExitBarrier): void {
const barriers = this.barriersByGeneration.get(providerGeneration)
barriers?.delete(barrier)
if (barriers?.size === 0) {
this.barriersByGeneration.delete(providerGeneration)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { SshPtyLegacyProjectionLedger } from './ssh-pty-legacy-projection'
import type { SshPtyModelAdmission } from './ssh-pty-model-admission'
import type {
SshPtyOutputExitEvent,
SshPtyOutputIntakeDependencies
} from './ssh-pty-output-intake-contract'
export async function settleSshPtyOutputExit(args: {
event: SshPtyOutputExitEvent
admission: SshPtyModelAdmission
projections: SshPtyLegacyProjectionLedger
dependencies: SshPtyOutputIntakeDependencies
validateGeneration: () => void
prepareExit?: () => void
afterAdmissionIdle?: () => void
waitForSourceTerminal?: () => Promise<void>
beforeFinalize?: () => void
}): Promise<void> {
const {
event,
admission,
projections,
dependencies,
validateGeneration,
prepareExit,
afterAdmissionIdle,
waitForSourceTerminal,
beforeFinalize
} = args
await admission.whenIdle({
ptyId: event.id,
providerGeneration: event.providerGeneration
})
validateGeneration()
afterAdmissionIdle?.()
try {
if (prepareExit) {
prepareExit()
} else {
dependencies.prepareExit(event)
}
} catch (error) {
projections.closePty(
event.id,
event.providerGeneration,
event.ptyIncarnation,
'pty-exit-finalize-failed'
)
dependencies.closeProvider?.(event.providerGeneration, 'pty-exit-finalize-failed')
throw error
}
projections.transferUnpublishedPty(
event.id,
event.providerGeneration,
event.ptyIncarnation,
'pty-exit-unpublished'
)
await projections.whenPtyTerminal(event.id, event.providerGeneration, event.ptyIncarnation)
await waitForSourceTerminal?.()
validateGeneration()
try {
beforeFinalize?.()
dependencies.finalizeExit(event)
projections.closePty(event.id, event.providerGeneration, event.ptyIncarnation, 'pty-exit')
} catch (error) {
projections.closePty(
event.id,
event.providerGeneration,
event.ptyIncarnation,
'pty-exit-finalize-failed'
)
dependencies.closeProvider?.(event.providerGeneration, 'pty-exit-finalize-failed')
throw error
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { SshPtyOutputGenerationGuard } from './ssh-pty-output-generation-guard'
const event = (providerGeneration: number) => ({
id: 'pty-1',
providerGeneration,
ptyIncarnation: 'incarnation-1'
})
describe('SshPtyOutputGenerationGuard', () => {
it('compacts sequential closures without weakening stale rejection', () => {
const guard = new SshPtyOutputGenerationGuard(() => false)
for (let generation = 1; generation <= 2_048; generation++) {
guard.closeGeneration(generation)
}
expect(guard.getDebugSnapshot()).toEqual({
closedRanges: 1,
activeGaps: 0,
activePtys: 0,
sealedPtys: 0
})
expect(() => guard.validate(event(1_024))).toThrow('ssh_output_stale_generation')
expect(() => guard.validate(event(2_049))).not.toThrow()
})
it('counts and merges out-of-order live generation gaps exactly', () => {
const guard = new SshPtyOutputGenerationGuard(() => false)
guard.closeGeneration(4)
guard.closeGeneration(2)
expect(guard.getDebugSnapshot()).toMatchObject({ closedRanges: 2, activeGaps: 2 })
guard.closeGeneration(3)
expect(guard.getDebugSnapshot()).toMatchObject({ closedRanges: 1, activeGaps: 1 })
expect(() => guard.validate(event(1))).not.toThrow()
expect(() => guard.validate(event(2))).toThrow('ssh_output_stale_generation')
guard.closeGeneration(1)
expect(guard.getDebugSnapshot()).toEqual({
closedRanges: 1,
activeGaps: 0,
activePtys: 0,
sealedPtys: 0
})
})
})
@@ -0,0 +1,107 @@
import type { SshPtyOutputDataEvent, SshPtyOutputExitEvent } from './ssh-pty-output-intake-contract'
import {
outputIntakeError,
sshPtyGenerationKey,
validOutputLength
} from './ssh-pty-output-intake-validation'
import { SshPtyClosedGenerationRanges } from './ssh-pty-closed-generation-ranges'
export class SshPtyOutputGenerationGuard {
private readonly latestGenerationByPty = new Map<string, number>()
private readonly incarnationByPty = new Map<string, string>()
private readonly sealedPtys = new Set<string>()
private readonly closedGenerations = new SshPtyClosedGenerationRanges()
constructor(private readonly isDisposed: () => boolean) {}
validateData(event: SshPtyOutputDataEvent): void {
if (
!event.id ||
!event.ptyIncarnation ||
!Number.isSafeInteger(event.providerGeneration) ||
event.providerGeneration <= 0 ||
!validOutputLength(event.rawLength) ||
(event.source !== undefined &&
(!event.source.spanId ||
!event.source.deliveryToken ||
!Number.isSafeInteger(event.source.clientGeneration) ||
event.source.clientGeneration <= 0 ||
!Number.isSafeInteger(event.source.ownerGeneration) ||
event.source.ownerGeneration <= 0 ||
!Number.isSafeInteger(event.source.sourceStartSu) ||
event.source.sourceStartSu < 0 ||
!Number.isSafeInteger(event.source.sourceEndSu) ||
event.source.sourceEndSu - event.source.sourceStartSu !== event.rawLength))
) {
throw outputIntakeError('ssh_output_invalid_event')
}
this.validate(event)
if (this.sealedPtys.has(sshPtyGenerationKey(event.id, event.providerGeneration))) {
throw outputIntakeError('ssh_output_after_exit')
}
}
sealExit(event: SshPtyOutputExitEvent): void {
this.validate(event)
const key = sshPtyGenerationKey(event.id, event.providerGeneration)
if (this.sealedPtys.has(key)) {
throw outputIntakeError('ssh_output_duplicate_exit')
}
this.sealedPtys.add(key)
}
validate(event: { id: string; providerGeneration: number; ptyIncarnation: string }): void {
if (this.isDisposed()) {
throw outputIntakeError('ssh_output_intake_disposed')
}
if (this.closedGenerations.has(event.providerGeneration)) {
throw outputIntakeError('ssh_output_stale_generation')
}
const generation = this.latestGenerationByPty.get(event.id)
if (generation !== undefined && event.providerGeneration < generation) {
throw outputIntakeError('ssh_output_stale_generation')
}
const incarnation = this.incarnationByPty.get(event.id)
if (
generation === event.providerGeneration &&
incarnation !== undefined &&
incarnation !== event.ptyIncarnation
) {
throw outputIntakeError('ssh_output_stale_incarnation')
}
if (generation === undefined || event.providerGeneration > generation) {
this.latestGenerationByPty.set(event.id, event.providerGeneration)
this.incarnationByPty.set(event.id, event.ptyIncarnation)
}
}
closeGeneration(providerGeneration: number): void {
this.closedGenerations.add(providerGeneration)
for (const [ptyId, generation] of this.latestGenerationByPty) {
if (generation === providerGeneration) {
this.latestGenerationByPty.delete(ptyId)
this.incarnationByPty.delete(ptyId)
this.sealedPtys.delete(sshPtyGenerationKey(ptyId, generation))
}
}
const prefix = `${providerGeneration}\0`
for (const key of this.sealedPtys) {
if (key.startsWith(prefix)) {
this.sealedPtys.delete(key)
}
}
}
activeGenerations(): ReadonlySet<number> {
return new Set(this.latestGenerationByPty.values())
}
getDebugSnapshot() {
return {
closedRanges: this.closedGenerations.size,
activeGaps: this.closedGenerations.activeGaps,
activePtys: this.latestGenerationByPty.size,
sealedPtys: this.sealedPtys.size
}
}
}
@@ -0,0 +1,77 @@
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
import type {
SshPtyModelAdmissionOptions,
SshPtyModelAdmissionReceipt
} from './ssh-pty-model-admission-contract'
import type { PtySourceCreditAckBatch } from '../../shared/pty-source-credit-contract'
export type SshPtySourceCancellationRequest = Readonly<{
id: string
clientGeneration: number
ownerGeneration: number
deliveryToken: string
}>
export type SshPtySourceCancellationProof = Readonly<{
sentEndSu: number
creditedEndSu: number
}>
export type SshPtyOutputDataEvent = Readonly<{
id: string
data: string
providerGeneration: number
ptyIncarnation: string
rawLength: number
transformed: boolean
sequence?: number
source?: Readonly<{
relayPtyId?: string
spanId: string
clientGeneration: number
ownerGeneration: number
deliveryToken: string
sourceStartSu: number
sourceEndSu: number
}>
}>
export type SshPtyOutputExitEvent = Readonly<{
id: string
code: number
providerGeneration: number
ptyIncarnation: string
}>
export type SshPtyOutputReceipt = SshPtyModelAdmissionReceipt &
Readonly<{ projection: LegacySshProjectionSemantics }>
export type SshPtyOutputIntakeDependencies = {
getModelSequence: (id: string) => number
acceptModel: (
event: SshPtyOutputDataEvent,
projection: LegacySshProjectionSemantics
) => { sequence: number; completion: Promise<void> }
project: (event: SshPtyOutputDataEvent, projection: LegacySshProjectionSemantics) => void
prepareExit: (event: SshPtyOutputExitEvent) => void | (() => void)
finalizeExit: (event: SshPtyOutputExitEvent) => void
pauseProvider?: (providerGeneration: number, id: string) => boolean
resumeProvider?: (providerGeneration: number, id: string) => void
closeProvider?: (providerGeneration: number, reason: string) => void
resetModelForMigration?: (providerGeneration: number, id: string) => void
onGenerationClosed?: (providerGeneration: number, reason: string) => void
publishSourceAck?: (
providerGeneration: number,
batch: PtySourceCreditAckBatch,
onSettled: (result: { ok: true } | { ok: false; error: Error }) => void
) => void
cancelSourceDelivery?: (
providerGeneration: number,
request: SshPtySourceCancellationRequest
) => Promise<SshPtySourceCancellationProof>
}
export type SshPtyOutputIntakeOptions = SshPtyModelAdmissionOptions & {
exitBarrierMs?: number
exitCancellationProofMs?: number
}
@@ -0,0 +1,151 @@
import type { SshPtyOutputIntake } from './ssh-pty-output-intake'
import type {
SshPtyOutputDataEvent,
SshPtyOutputExitEvent,
SshPtyOutputReceipt,
SshPtySourceCancellationProof,
SshPtySourceCancellationRequest
} from './ssh-pty-output-intake-contract'
import type { SshPtyAcceptedSourceCheckpoint } from './ssh-pty-output-source-obligations'
import type { PtySourceCreditAckBatch } from '../../shared/pty-source-credit-contract'
import type { SshPtyOutputGenerationMigration } from './ssh-pty-output-model-migration'
let installedIntake: SshPtyOutputIntake | null = null
let nextProviderGeneration = 1
const sourceAckPublishers = new Map<number, SshPtySourceAckPublisher>()
const sourceCancellationPublishers = new Map<number, SshPtySourceCancellationPublisher>()
type SshPtySourceAckPublisher = (
batch: PtySourceCreditAckBatch,
onSettled: (result: { ok: true } | { ok: false; error: Error }) => void
) => void
type SshPtySourceCancellationPublisher = (
request: SshPtySourceCancellationRequest
) => Promise<SshPtySourceCancellationProof>
export function allocateSshPtyProviderGeneration(): number {
return nextProviderGeneration++
}
export function installSshPtyOutputIntake(intake: SshPtyOutputIntake): () => void {
const previous = installedIntake
installedIntake = intake
previous?.dispose()
return () => {
if (installedIntake === intake) {
installedIntake = null
intake.dispose()
}
}
}
export function acceptSshPtyOutputData(event: SshPtyOutputDataEvent): Promise<SshPtyOutputReceipt> {
return installedIntake
? installedIntake.acceptData(event)
: Promise.reject(outputIntakeUnavailableError())
}
export function acceptSshPtyOutputExit(event: SshPtyOutputExitEvent): Promise<void> {
return installedIntake
? installedIntake.acceptExit(event)
: Promise.reject(outputIntakeUnavailableError())
}
export function closeSshPtyOutputGeneration(providerGeneration: number, reason: string): void {
installedIntake?.closeGeneration(providerGeneration, reason)
}
export function getSshPtyAcceptedSourceCheckpoints(
providerGeneration: number
): readonly SshPtyAcceptedSourceCheckpoint[] {
return installedIntake?.getAcceptedSourceCheckpoints(providerGeneration) ?? []
}
export function beginSshPtyOutputGenerationMigration(
providerGeneration: number
): SshPtyOutputGenerationMigration {
return (
installedIntake?.beginGenerationMigration(providerGeneration) ?? {
byPty: new Map(),
completion: Promise.resolve()
}
)
}
export function installSshPtySourceAckPublisher(
providerGeneration: number,
publish: SshPtySourceAckPublisher
): () => void {
if (sourceAckPublishers.has(providerGeneration)) {
throw new Error('ssh_source_ack_publisher_duplicate_generation')
}
sourceAckPublishers.set(providerGeneration, publish)
return () => {
if (sourceAckPublishers.get(providerGeneration) === publish) {
sourceAckPublishers.delete(providerGeneration)
}
}
}
export function publishSshPtySourceAck(
providerGeneration: number,
batch: PtySourceCreditAckBatch,
onSettled: (result: { ok: true } | { ok: false; error: Error }) => void
): void {
const publisher = sourceAckPublishers.get(providerGeneration)
if (!publisher) {
onSettled({ ok: false, error: new Error('ssh_source_ack_publisher_unavailable') })
return
}
publisher(batch, onSettled)
}
export function installSshPtySourceCancellationPublisher(
providerGeneration: number,
cancel: SshPtySourceCancellationPublisher
): () => void {
if (sourceCancellationPublishers.has(providerGeneration)) {
throw new Error('ssh_source_cancellation_publisher_duplicate_generation')
}
sourceCancellationPublishers.set(providerGeneration, cancel)
return () => {
if (sourceCancellationPublishers.get(providerGeneration) === cancel) {
sourceCancellationPublishers.delete(providerGeneration)
}
}
}
export function cancelSshPtySourceDelivery(
providerGeneration: number,
request: SshPtySourceCancellationRequest
): Promise<SshPtySourceCancellationProof> {
const publisher = sourceCancellationPublishers.get(providerGeneration)
return publisher
? publisher(request)
: Promise.reject(new Error('ssh_source_cancellation_publisher_unavailable'))
}
export function applySshPtySourceCancellationProof(
event: SshPtyOutputExitEvent,
proof: SshPtySourceCancellationProof
): boolean {
return installedIntake?.applySourceCancellationProof(event, proof) ?? false
}
export function applySshPtySourceRecoveryCancellationProof(
event: SshPtyOutputExitEvent,
proof: SshPtySourceCancellationProof
): boolean {
if (!installedIntake) {
return false
}
installedIntake.applySourceRecoveryCancellationProof(event, proof)
return true
}
function outputIntakeUnavailableError(): Error {
return Object.assign(new Error('ssh_output_intake_unavailable'), {
code: 'ssh_output_intake_unavailable'
})
}
@@ -0,0 +1,62 @@
import { vi } from 'vitest'
import {
SshPtyOutputIntake,
type SshPtyOutputDataEvent,
type SshPtyOutputIntakeDependencies
} from './ssh-pty-output-intake'
export function sshPtyOutputEvent(
overrides: Partial<SshPtyOutputDataEvent> = {}
): SshPtyOutputDataEvent {
return {
id: 'pty-1',
data: 'aaaa',
providerGeneration: 1,
ptyIncarnation: 'incarnation-1',
rawLength: 4,
transformed: false,
...overrides
}
}
export function createSshPtyOutputIntakeHarness(
overrides: Partial<SshPtyOutputIntakeDependencies> = {},
options: ConstructorParameters<typeof SshPtyOutputIntake>[1] = {}
) {
let sequence = 0
const completions: ReturnType<typeof deferred>[] = []
const order: string[] = []
const dependencies: SshPtyOutputIntakeDependencies = {
getModelSequence: () => sequence,
acceptModel: (input) => {
order.push(`model:${input.data}`)
sequence += input.rawLength
const completion = deferred()
completions.push(completion)
return { sequence, completion: completion.promise }
},
project: (input) => order.push(`project:${input.data}`),
prepareExit: vi.fn(),
finalizeExit: () => order.push('exit'),
pauseProvider: vi.fn(() => true),
resumeProvider: vi.fn(),
closeProvider: vi.fn(),
...overrides
}
return {
intake: new SshPtyOutputIntake(dependencies, options),
dependencies,
completions,
order
}
}
function deferred() {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
@@ -0,0 +1,16 @@
export type SshPtyExitBarrier = {
timer: ReturnType<typeof setTimeout>
reject: (error: Error) => void
}
export function outputIntakeError(code: string): Error {
return Object.assign(new Error(code), { code })
}
export function validOutputLength(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0
}
export function sshPtyGenerationKey(ptyId: string, providerGeneration: number): string {
return `${providerGeneration}\0${ptyId}`
}
+760
View File
@@ -0,0 +1,760 @@
import { describe, expect, it, vi } from 'vitest'
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
import {
createSshPtyOutputIntakeHarness as createHarness,
sshPtyOutputEvent as event
} from './ssh-pty-output-intake-test-harness'
describe('SshPtyOutputIntake', () => {
it('plateaus at the model and pressure budgets, then resumes below low water', async () => {
const harness = createHarness(
{},
{
perPtyHighSourceUnits: 4,
perPtyHighBytes: 1024,
perPtyLowSourceUnits: 1,
perPtyLowBytes: 256,
globalHighSourceUnits: 4,
globalHighBytes: 1024,
globalLowSourceUnits: 1,
globalLowBytes: 256,
pressureMaxFrames: 2,
pressureMaxBytes: 1024
}
)
const first = harness.intake.acceptData(event())
const second = harness.intake.acceptData(event({ data: 'bbbb' }))
const third = harness.intake.acceptData(event({ data: 'cccc' }))
const rejected = harness.intake.acceptData(event({ data: 'dddd' }))
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
sourceUnits: 4,
pressureFrames: 2
})
await expect(rejected).rejects.toThrow('ssh_model_admission_pressure_exhausted')
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, expect.any(String))
harness.completions[0]!.resolve()
await first
harness.completions[1]!.resolve()
await second
harness.completions[2]!.resolve()
await third
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
sourceUnits: 0,
bytes: 0,
pressureFrames: 0
})
expect(harness.dependencies.resumeProvider).toHaveBeenCalled()
})
it('preserves per-PTY FIFO while differently sized pressure entries wait', async () => {
const harness = createHarness(
{},
{
perPtyHighSourceUnits: 4,
perPtyHighBytes: 4096,
globalHighSourceUnits: 4,
globalHighBytes: 4096,
pressureMaxFrames: 4,
pressureMaxBytes: 4096
}
)
const receipts = [
harness.intake.acceptData(event({ data: 'a', rawLength: 1 })),
harness.intake.acceptData(event({ data: 'bbb', rawLength: 3 })),
harness.intake.acceptData(event({ data: 'cc', rawLength: 2 })),
harness.intake.acceptData(event({ data: 'd', rawLength: 1 }))
]
harness.completions[0]!.resolve()
await receipts[0]
harness.completions[1]!.resolve()
await receipts[1]
harness.completions[2]!.resolve()
await receipts[2]
harness.completions[3]!.resolve()
await receipts[3]
expect(harness.order).toEqual([
'model:a',
'project:a',
'model:bbb',
'project:bbb',
'model:cc',
'project:cc',
'model:d',
'project:d'
])
})
it('assigns projection sequence ends when queued model capture begins', async () => {
const harness = createHarness(
{},
{
perPtyHighSourceUnits: 12,
perPtyHighBytes: 4096,
globalHighSourceUnits: 12,
globalHighBytes: 4096
}
)
const first = harness.intake.acceptData(event({ data: 'aaaa' }))
const second = harness.intake.acceptData(event({ data: 'bbbb' }))
const third = harness.intake.acceptData(event({ data: 'cccc' }))
harness.completions[0]!.resolve()
expect((await first).projection.identity.sequenceEnd).toBe(4)
harness.completions[1]!.resolve()
expect((await second).projection.identity.sequenceEnd).toBe(8)
harness.completions[2]!.resolve()
expect((await third).projection.identity.sequenceEnd).toBe(12)
})
it('transfers projection state and closes the provider on model failure', async () => {
const harness = createHarness()
const receipt = harness.intake.acceptData(event())
harness.completions[0]!.reject(new Error('emulator failed'))
await expect(receipt).rejects.toThrow('emulator failed')
expect(harness.intake.getDebugSnapshot().projection.transferred).toBe(1)
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, 'model-admission-failed')
})
it('transfers a committed projection when desktop admission throws', async () => {
const projections: LegacySshProjectionSemantics[] = []
const harness = createHarness({
project: (_event, projection) => {
projections.push(projection)
if (projections.length === 1) {
throw new Error('send failed')
}
}
})
const receipt = harness.intake.acceptData(event({ data: '\x1b[?20', rawLength: 5 }))
harness.completions[0]!.resolve()
await expect(receipt).resolves.toMatchObject({ sequence: 5 })
expect(harness.intake.getDebugSnapshot().projection.transferred).toBe(1)
const next = harness.intake.acceptData(event({ data: '31h', rawLength: 3 }))
harness.completions[1]!.resolve()
await next
expect(projections[1]).toMatchObject({
identity: { displayStart: 5 },
beforeScanner: { tail: '\x1b[?20', pendingSubscribe: false },
decision: 'subscribed'
})
})
it('commits an immutable desktop source identity through projection admission', async () => {
const projections: LegacySshProjectionSemantics[] = []
const harness = createHarness({
project: (_event, projection) => projections.push(projection)
})
const receipt = harness.intake.acceptData(
event({
source: {
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 10,
sourceEndSu: 14
}
})
)
harness.completions[0]!.resolve()
await receipt
expect(projections[0]?.desktopSpan).toMatchObject({
spanId: 'span-1',
projectionSemanticsId: projections[0]?.identity.projectionSemanticsId,
providerGeneration: 1,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
sourceStartSu: 10,
sourceEndSu: 14,
displayStart: 0,
displayEnd: 4,
transform: { transformed: false, rawLengthSu: 4, scalarSafe: true }
})
expect(Object.isFrozen(projections[0]?.desktopSpan)).toBe(true)
})
it('exports only the model-settled source boundary before generation close', async () => {
const harness = createHarness()
const receipt = harness.intake.acceptData(
event({
source: {
spanId: 'recovery-span-1',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'delivery-token-1',
sourceStartSu: 10,
sourceEndSu: 14
}
})
)
expect(harness.intake.getAcceptedSourceCheckpoints(1)[0]?.acceptedSourceEndSu).toBe(10)
harness.completions[0]!.resolve()
await receipt
expect(harness.intake.getAcceptedSourceCheckpoints(1)).toEqual([
{
id: 'pty-1',
providerGeneration: 1,
clientGeneration: 3,
ownerGeneration: 4,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'delivery-token-1',
acceptedSourceEndSu: 14
}
])
})
it('rolls back projection staging when model capture throws synchronously', async () => {
const project = vi.fn()
const harness = createHarness({
acceptModel: () => {
throw new Error('model reservation failed')
},
project
})
await expect(harness.intake.acceptData(event())).rejects.toThrow('model reservation failed')
expect(project).not.toHaveBeenCalled()
expect(harness.intake.getDebugSnapshot().projection).toMatchObject({
rolledBack: 1,
records: 0
})
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, 'model-admission-failed')
})
it('rolls back projection staging when source reservation validation fails', async () => {
const harness = createHarness()
const source = {
spanId: 'duplicate',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
const first = harness.intake.acceptData(event({ source }))
harness.completions[0]!.resolve()
await first
await expect(
harness.intake.acceptData(
event({
source: { ...source, sourceStartSu: 4, sourceEndSu: 8 }
})
)
).rejects.toThrow('duplicate')
expect(harness.intake.getDebugSnapshot().projection).toMatchObject({
rolledBack: 1,
records: 1
})
})
it('rolls back committed source and scanner facts when model capture throws', async () => {
let attempts = 0
const harness = createHarness({
acceptModel: (accepted) => {
attempts++
if (attempts === 1) {
throw new Error('model reservation failed')
}
return {
sequence: accepted.rawLength,
completion: Promise.resolve()
}
}
})
const source = {
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 5
}
await expect(
harness.intake.acceptData(event({ data: '\x1b[?20', rawLength: 5, source }))
).rejects.toThrow('model reservation failed')
const retry = await harness.intake.acceptData(
event({ data: '\x1b[?20', rawLength: 5, source: { ...source, spanId: 'span-2' } })
)
expect(retry.projection.identity.displayStart).toBe(0)
expect(retry.projection.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
})
it('rejects stale provider generations without model capture', async () => {
const harness = createHarness()
const current = harness.intake.acceptData(event({ providerGeneration: 2 }))
harness.completions[0]!.resolve()
await current
await expect(harness.intake.acceptData(event({ providerGeneration: 1 }))).rejects.toThrow(
'ssh_output_stale_generation'
)
expect(harness.completions).toHaveLength(1)
})
it('keeps exit behind accepted model and projection work', async () => {
const harness = createHarness({}, { exitBarrierMs: 1000 })
const dataReceipt = harness.intake.acceptData(event())
const exitReceipt = harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
await Promise.resolve()
expect(harness.order).toEqual(['model:aaaa', 'project:aaaa'])
harness.completions[0]!.resolve()
await Promise.all([dataReceipt, exitReceipt])
expect(harness.order).toEqual(['model:aaaa', 'project:aaaa', 'exit'])
})
it('admits queued pre-exit source spans before sealing the token', async () => {
const harness = createHarness({}, { exitBarrierMs: 1000 })
const first = harness.intake.acceptData(
event({
source: {
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
const second = harness.intake.acceptData(
event({
data: 'bbbb',
source: {
spanId: 'span-2',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 4,
sourceEndSu: 8
}
})
)
const exit = harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
harness.completions[0]!.resolve()
await first
harness.completions[1]!.resolve()
await Promise.all([second, exit])
expect(harness.order).toEqual([
'model:aaaa',
'project:aaaa',
'model:bbbb',
'project:bbbb',
'exit'
])
})
it('does not pump queued model work after a running completion fails', async () => {
const harness = createHarness()
const first = harness.intake.acceptData(event({ data: 'first' }))
const queued = harness.intake.acceptData(event({ data: 'queued' }))
harness.completions[0]!.reject(new Error('emulator failed'))
await expect(first).rejects.toThrow('emulator failed')
await expect(queued).rejects.toThrow('ssh_model_admission_completion_failed')
expect(harness.order).toEqual(['model:first', 'project:first'])
expect(harness.completions).toHaveLength(1)
})
it('retains exited delivery until published renderer projections settle', async () => {
const harness = createHarness({}, { exitBarrierMs: 1000 })
const dataReceipt = harness.intake.acceptData(event())
harness.completions[0]!.resolve()
const receipt = await dataReceipt
harness.intake.publishProjectionPrefix(
[receipt.projection.identity.projectionSemanticsId],
4,
4
)
let exited = false
const exitReceipt = harness.intake
.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
.then(() => {
exited = true
})
await Promise.resolve()
expect(exited).toBe(false)
expect(harness.intake.getDebugSnapshot().projection.records).toBe(1)
expect(harness.intake.settleProjectionPrefix('pty-1', 4)).toBe(4)
await exitReceipt
expect(harness.order.at(-1)).toBe('exit')
expect(harness.intake.getDebugSnapshot().projection.records).toBe(0)
})
it('owns renderer exit preparation through finalization and duplicate rejection', async () => {
const releaseRendererExit = vi.fn()
const harness = createHarness({
prepareExit: vi.fn(() => releaseRendererExit)
})
const dataReceipt = harness.intake.acceptData(event())
harness.completions[0]!.resolve()
const receipt = await dataReceipt
harness.intake.publishProjectionPrefix(
[receipt.projection.identity.projectionSemanticsId],
4,
4
)
const exitEvent = {
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
}
const exit = harness.intake.acceptExit(exitEvent)
await Promise.resolve()
expect(releaseRendererExit).not.toHaveBeenCalled()
await expect(harness.intake.acceptExit(exitEvent)).rejects.toThrow('ssh_output_duplicate_exit')
expect(releaseRendererExit).not.toHaveBeenCalled()
harness.intake.settleProjectionPrefix('pty-1', 4)
await exit
expect(releaseRendererExit).toHaveBeenCalledOnce()
})
it('releases renderer exit preparation when generation close aborts finalization', async () => {
const releaseRendererExit = vi.fn()
const harness = createHarness({
prepareExit: vi.fn(() => releaseRendererExit)
})
const dataReceipt = harness.intake.acceptData(event())
harness.completions[0]!.resolve()
const receipt = await dataReceipt
harness.intake.publishProjectionPrefix(
[receipt.projection.identity.projectionSemanticsId],
4,
4
)
const exit = harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
await Promise.resolve()
harness.intake.closeGeneration(1, 'provider-replaced')
await expect(exit).rejects.toThrow('provider-replaced')
expect(releaseRendererExit).toHaveBeenCalledOnce()
expect(harness.order).not.toContain('exit')
})
it('retains exit until a required remote source consumer settles', async () => {
const harness = createHarness({}, { exitBarrierMs: 1000 })
const remote = harness.intake.getRemoteSourceRangeConsumerHooks()
const stream = {
ptyId: 'pty-1',
consumerId: 'remote-1',
streamGeneration: 'stream-1'
}
expect(remote.attach(stream)).toBe(true)
const dataReceipt = harness.intake.acceptData(
event({
source: {
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
harness.completions[0]!.resolve()
const receipt = await dataReceipt
let exited = false
const exitReceipt = harness.intake
.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
.then(() => {
exited = true
})
await Promise.resolve()
expect(exited).toBe(false)
remote.settle(stream, [receipt.projection.desktopSpan!])
await exitReceipt
expect(harness.order.at(-1)).toBe('exit')
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
})
it('keeps timed-out exit projections until generation-close proof', async () => {
const harness = createHarness(
{
cancelSourceDelivery: () => Promise.reject(new Error('cancel transport failed'))
},
{ exitBarrierMs: 1, exitCancellationProofMs: 10 }
)
const dataReceipt = harness.intake.acceptData(event())
harness.completions[0]!.resolve()
const receipt = await dataReceipt
harness.intake.publishProjectionPrefix(
[receipt.projection.identity.projectionSemanticsId],
4,
4
)
await expect(
harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
).rejects.toThrow('ssh_source_cancellation_identity_unavailable')
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(
1,
'ssh-exit-cancellation-proof-failed'
)
expect(harness.intake.getDebugSnapshot().projection.records).toBe(1)
harness.intake.closeGeneration(1, 'provider-closed')
expect(harness.intake.getDebugSnapshot().projection.records).toBe(0)
})
it('cancels only the timed-out source delivery and keeps the provider usable', async () => {
const cancelSourceDelivery = vi.fn(async () => ({ sentEndSu: 4, creditedEndSu: 0 }))
const harness = createHarness(
{ cancelSourceDelivery },
{ exitBarrierMs: 1, exitCancellationProofMs: 100 }
)
const remote = harness.intake.getRemoteSourceRangeConsumerHooks()
const stream = {
ptyId: 'pty-1',
consumerId: 'remote-1',
streamGeneration: 'stream-1'
}
remote.attach(stream)
const dataReceipt = harness.intake.acceptData(
event({
source: {
spanId: 'span-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
harness.completions[0]!.resolve()
await dataReceipt
await harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
expect(cancelSourceDelivery).toHaveBeenCalledWith(1, {
id: 'pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1'
})
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
expect(harness.order.at(-1)).toBe('exit')
const sibling = harness.intake.acceptData(
event({ id: 'pty-2', ptyIncarnation: 'incarnation-2' })
)
harness.completions[1]!.resolve()
await expect(sibling).resolves.toMatchObject({ ptyId: 'pty-2' })
})
it('accepts recovery cancellation proof before any replacement span is admitted', () => {
const harness = createHarness()
expect(() =>
harness.intake.applySourceRecoveryCancellationProof(
{
id: 'pty-1',
code: -1,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
},
{ sentEndSu: 8, creditedEndSu: 4 }
)
).not.toThrow()
expect(harness.intake.getDebugSnapshot().source).toEqual({
openedTokens: 0,
ptyIdentities: 0
})
})
it('reclaims a partially admitted recovery prefix from authoritative proof', async () => {
const harness = createHarness()
const receipt = harness.intake.acceptData(
event({
source: {
spanId: 'recovery-span',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'recovery-token',
sourceStartSu: 4,
sourceEndSu: 8
}
})
)
harness.completions[0]!.resolve()
await receipt
harness.intake.applySourceRecoveryCancellationProof(
{
id: 'pty-1',
code: -1,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
},
{ sentEndSu: 12, creditedEndSu: 4 }
)
expect(harness.intake.getDebugSnapshot().source).toEqual({
openedTokens: 0,
ptyIdentities: 0
})
})
it('rejects late same-generation data after ordered exit cleanup', async () => {
const harness = createHarness({}, { exitBarrierMs: 1000 })
const first = harness.intake.acceptData(event())
harness.completions[0]!.resolve()
await first
await harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
await expect(harness.intake.acceptData(event())).rejects.toThrow('ssh_output_after_exit')
await expect(
harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
).rejects.toThrow('ssh_output_duplicate_exit')
const next = harness.intake.acceptData(
event({ providerGeneration: 2, ptyIncarnation: 'incarnation-2' })
)
harness.completions[1]!.resolve()
await expect(next).resolves.toMatchObject({
projection: { identity: { ptyIncarnation: 'incarnation-2', displayStart: 0 } }
})
})
it('closes the provider when exit finalization fails', async () => {
const releaseRendererExit = vi.fn()
const harness = createHarness({
prepareExit: () => releaseRendererExit,
finalizeExit: () => {
throw new Error('renderer exit send failed')
}
})
await expect(
harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
).rejects.toThrow('renderer exit send failed')
expect(harness.dependencies.closeProvider).toHaveBeenCalledWith(1, 'pty-exit-finalize-failed')
expect(releaseRendererExit).toHaveBeenCalledOnce()
})
it('cancels queued work and exit waiters on generation cleanup', async () => {
const harness = createHarness(
{},
{
perPtyHighSourceUnits: 8,
perPtyHighBytes: 2048,
globalHighSourceUnits: 8,
globalHighBytes: 2048
}
)
const running = harness.intake.acceptData(event())
const queued = harness.intake.acceptData(event({ data: 'bbbb' }))
const exit = harness.intake.acceptExit({
id: 'pty-1',
code: 0,
providerGeneration: 1,
ptyIncarnation: 'incarnation-1'
})
harness.intake.closeGeneration(1, 'provider-closed')
await expect(queued).rejects.toThrow('provider-closed')
await expect(exit).rejects.toThrow('provider-closed')
await expect(harness.intake.acceptData(event())).rejects.toThrow('ssh_output_stale_generation')
await expect(running).rejects.toThrow('provider-closed')
expect(harness.intake.getDebugSnapshot().model).toMatchObject({ sourceUnits: 0, bytes: 0 })
harness.completions[0]!.resolve()
await Promise.resolve()
expect(harness.intake.getDebugSnapshot().exitBarriers).toBe(0)
})
it('resumes a paused provider during generation cleanup', async () => {
const harness = createHarness(
{},
{
perPtyHighSourceUnits: 4,
perPtyHighBytes: 1024,
globalHighSourceUnits: 4,
globalHighBytes: 1024,
pressureMaxFrames: 2,
pressureMaxBytes: 1024
}
)
const running = harness.intake.acceptData(event())
const pressured = harness.intake.acceptData(event({ data: 'bbbb' }))
harness.intake.closeGeneration(1, 'provider-closed')
await expect(pressured).rejects.toThrow('provider-closed')
expect(harness.dependencies.resumeProvider).toHaveBeenCalledWith(1, 'pty-1')
await expect(running).rejects.toThrow('provider-closed')
harness.completions[0]!.resolve()
await Promise.resolve()
})
})
+296
View File
@@ -0,0 +1,296 @@
import {
SshPtyLegacyProjectionLedger,
type LegacySshProjectionSemantics
} from './ssh-pty-legacy-projection'
import { SshPtyModelAdmission } from './ssh-pty-model-admission'
import { SshPtyOutputExitDeadline } from './ssh-pty-output-exit-deadline'
import { settleSshPtyOutputExit } from './ssh-pty-output-exit'
import { SshPtyOutputGenerationGuard } from './ssh-pty-output-generation-guard'
import {
SshPtyOutputModelMigration,
type SshPtyOutputGenerationMigration,
type SshPtyTrackedModelAdmission
} from './ssh-pty-output-model-migration'
import {
SshPtyOutputSourceObligations,
type SshPtyOutputSourceReservation
} from './ssh-pty-output-source-obligations'
import type {
SshPtyOutputDataEvent,
SshPtyOutputExitEvent,
SshPtyOutputIntakeDependencies,
SshPtyOutputIntakeOptions,
SshPtyOutputReceipt
} from './ssh-pty-output-intake-contract'
import { outputIntakeError } from './ssh-pty-output-intake-validation'
export type {
SshPtyOutputDataEvent,
SshPtyOutputExitEvent,
SshPtyOutputIntakeDependencies,
SshPtyOutputIntakeOptions,
SshPtyOutputReceipt
} from './ssh-pty-output-intake-contract'
export class SshPtyOutputIntake {
private readonly projections: SshPtyLegacyProjectionLedger
private readonly sourceObligations: SshPtyOutputSourceObligations
private readonly generationGuard = new SshPtyOutputGenerationGuard(() => this.disposed)
private readonly admission: SshPtyModelAdmission
private readonly modelMigration: SshPtyOutputModelMigration
private readonly exitDeadline: SshPtyOutputExitDeadline
private disposed = false
constructor(
private readonly dependencies: SshPtyOutputIntakeDependencies,
options: SshPtyOutputIntakeOptions = {}
) {
this.sourceObligations = new SshPtyOutputSourceObligations(dependencies.publishSourceAck)
this.projections = new SshPtyLegacyProjectionLedger({
onSettled: (span) => this.sourceObligations.settleDesktop(span, 'renderer-parse'),
onTransferred: (span, reason) => this.sourceObligations.transferDesktop(span, reason)
})
this.admission = new SshPtyModelAdmission({
...options,
pauseProvider: (key) =>
this.dependencies.pauseProvider?.(key.providerGeneration, key.ptyId) ?? false,
resumeProvider: (key) =>
this.dependencies.resumeProvider?.(key.providerGeneration, key.ptyId),
closeProvider: (providerGeneration, reason) =>
this.dependencies.closeProvider?.(providerGeneration, reason)
})
this.modelMigration = new SshPtyOutputModelMigration(
this.admission,
this.sourceObligations,
(providerGeneration, ptyId) =>
this.dependencies.resetModelForMigration?.(providerGeneration, ptyId)
)
this.exitDeadline = new SshPtyOutputExitDeadline({
admission: this.admission,
projections: this.projections,
sourceObligations: this.sourceObligations,
intake: this.dependencies,
barrierMs: options.exitBarrierMs,
cancellationProofMs: options.exitCancellationProofMs
})
}
acceptData(event: SshPtyOutputDataEvent): Promise<SshPtyOutputReceipt> {
try {
this.generationGuard.validateData(event)
} catch (error) {
return Promise.reject(error)
}
let projection: LegacySshProjectionSemantics | undefined
let sourceReservation: SshPtyOutputSourceReservation | undefined
const key = { ptyId: event.id, providerGeneration: event.providerGeneration }
const tracked: SshPtyTrackedModelAdmission = { key, started: false }
const receipt = this.admission.accept(key, event.data, event.rawLength, () => {
tracked.started = true
const expectedSequence = this.dependencies.getModelSequence(event.id) + event.rawLength
const reservation = this.projections.reserve({
ptyId: event.id,
providerGeneration: event.providerGeneration,
ptyIncarnation: event.ptyIncarnation,
data: event.data,
sequenceEnd: expectedSequence,
rawLength: event.rawLength,
transformed: event.transformed,
source: event.source
})
try {
if (reservation.semantics.desktopSpan) {
sourceReservation = this.sourceObligations.reserve(
event,
reservation.semantics.desktopSpan
)
}
} catch (error) {
this.projections.rollback(reservation)
throw error
}
try {
projection = this.projections.commit(reservation)
if (sourceReservation) {
this.sourceObligations.commit(
sourceReservation,
event.id,
projection.identity.sequenceEnd
)
}
} catch (error) {
if (sourceReservation) {
this.sourceObligations.rollback(sourceReservation)
}
if (!this.projections.rollbackCommitted(reservation)) {
this.projections.rollback(reservation)
}
throw error
}
let model: { sequence: number; completion: Promise<void> }
try {
model = this.dependencies.acceptModel(event, projection)
} catch (error) {
if (sourceReservation) {
this.sourceObligations.rollback(sourceReservation)
}
this.projections.rollbackCommitted(reservation)
throw error
}
try {
this.dependencies.project(event, projection)
} catch {
const id = projection.identity.projectionSemanticsId
this.projections.transfer([id], 'projection-admission-failed')
}
return model
})
const completion = receipt.then(
(modelReceipt) => {
if (!projection) {
throw outputIntakeError('ssh_projection_receipt_missing')
}
if (sourceReservation) {
this.sourceObligations.settleModel(sourceReservation.span)
}
return Object.freeze({ ...modelReceipt, projection })
},
(error) => {
if (projection) {
this.projections.transfer(
[projection.identity.projectionSemanticsId],
'model-admission-failed'
)
}
const code = (error as { code?: unknown }).code
if (
code !== 'ssh_exit_delivery_canceled' &&
!(typeof code === 'string' && code.startsWith('ssh_model_migration_'))
) {
this.dependencies.closeProvider?.(event.providerGeneration, 'model-admission-failed')
}
throw error
}
)
tracked.completion = completion
this.modelMigration.track(tracked)
return completion
}
async acceptExit(event: SshPtyOutputExitEvent): Promise<void> {
this.generationGuard.sealExit(event)
await this.exitDeadline.wait(event, (validateNormalExit) =>
this.finishExit(event, validateNormalExit)
)
}
private async finishExit(
event: SshPtyOutputExitEvent,
validateNormalExit: () => void
): Promise<void> {
await settleSshPtyOutputExit({
event,
admission: this.admission,
projections: this.projections,
dependencies: this.dependencies,
validateGeneration: () => {
this.generationGuard.validate(event)
validateNormalExit()
},
prepareExit: () => this.exitDeadline.prepareExitOnce(event),
afterAdmissionIdle: () => this.sourceObligations.sealPty(event),
waitForSourceTerminal: () => this.sourceObligations.whenPtyTerminal(event),
beforeFinalize: () => this.sourceObligations.markExitPublished(event)
})
}
publishProjectionPrefix(
ids: readonly string[],
displayChars: number,
accountingChars: number
): void {
this.projections.publishPrefix(ids, displayChars, accountingChars)
}
settleProjectionPrefix(ptyId: string, accountingChars: number): number {
return this.projections.settlePublishedPrefix(ptyId, accountingChars)
}
transferProjections(ids: readonly string[], reason: string): number {
return this.projections.transfer(ids, reason)
}
transferPtyProjections(ptyId: string, reason: string): number {
return this.projections.transferPty(ptyId, reason)
}
hasProjectionFromGeneration(ids: readonly string[], providerGeneration: number): boolean {
return ids.some(
(id) => this.projections.get(id)?.identity.providerGeneration === providerGeneration
)
}
hasUnpublishedProjection(id: string): boolean {
return this.projections.hasUnpublished(id)
}
closeGeneration(providerGeneration: number, reason: string): void {
this.generationGuard.closeGeneration(providerGeneration)
this.admission.closeGeneration(providerGeneration, reason)
this.dependencies.onGenerationClosed?.(providerGeneration, reason)
this.projections.closeGeneration(providerGeneration, reason)
this.sourceObligations.closeGeneration(providerGeneration, reason)
this.exitDeadline.closeGeneration(providerGeneration, outputIntakeError(reason))
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
for (const generation of this.generationGuard.activeGenerations()) {
this.closeGeneration(generation, 'ssh_output_intake_disposed')
}
this.admission.dispose()
this.sourceObligations.dispose()
}
getRemoteSourceRangeConsumerHooks() {
return this.sourceObligations.remoteHooks
}
getAcceptedSourceCheckpoints(providerGeneration: number) {
return this.sourceObligations.acceptedCheckpoints(providerGeneration)
}
beginGenerationMigration(
providerGeneration: number,
timeoutMs?: number
): SshPtyOutputGenerationMigration {
return this.modelMigration.beginGeneration(providerGeneration, timeoutMs)
}
applySourceCancellationProof(
event: SshPtyOutputExitEvent,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): boolean {
return this.sourceObligations.applyCancellationProof(event, proof)
}
applySourceRecoveryCancellationProof(
event: SshPtyOutputExitEvent,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): void {
this.sourceObligations.applyRecoveryCancellationProof(event, proof)
}
getDebugSnapshot() {
return {
model: this.admission.getDebugSnapshot(),
projection: this.projections.getDebugSnapshot(),
source: this.sourceObligations.getDebugSnapshot(),
generation: this.generationGuard.getDebugSnapshot(),
exitBarriers: this.exitDeadline.activeBarriers
}
}
}
@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from 'vitest'
import {
createSshPtyOutputIntakeHarness as createHarness,
sshPtyOutputEvent as event
} from './ssh-pty-output-intake-test-harness'
describe('SshPtyOutputModelMigration', () => {
it('fences a running source span before exporting its migration checkpoint', async () => {
const harness = createHarness()
const first = harness.intake.acceptData(
event({
data: 'aaaa',
source: {
spanId: 'span-a',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'delivery-token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
harness.completions[0]!.resolve()
await first
const second = harness.intake.acceptData(
event({
data: 'bbbb',
source: {
spanId: 'span-b',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'delivery-token-1',
sourceStartSu: 4,
sourceEndSu: 8
}
})
)
const migration = harness.intake.beginGenerationMigration(1)
const result = migration.byPty.get('pty-1')
expect(result).toBeDefined()
await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toBe('pending')
expect(harness.intake.getAcceptedSourceCheckpoints(1)[0]?.acceptedSourceEndSu).toBe(4)
harness.completions[1]!.resolve()
await expect(second).resolves.toMatchObject({ sequence: 8 })
await expect(result).resolves.toMatchObject({
status: 'settled',
checkpoint: { acceptedSourceEndSu: 8 }
})
expect(harness.order.filter((entry) => entry === 'project:bbbb')).toHaveLength(1)
await migration.completion
})
it('times out one migration, resets its model, and releases retained admission once', async () => {
vi.useFakeTimers()
try {
const resetModelForMigration = vi.fn()
const harness = createHarness({ resetModelForMigration })
const receipt = harness.intake.acceptData(
event({
source: {
spanId: 'span-b',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'delivery-token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
const migration = harness.intake.beginGenerationMigration(1, 10_000)
const result = migration.byPty.get('pty-1')
await vi.advanceTimersByTimeAsync(10_000)
await expect(result).resolves.toMatchObject({ status: 'checkpoint-unavailable' })
await expect(receipt).rejects.toThrow('ssh_model_migration_timeout')
expect(resetModelForMigration).toHaveBeenCalledOnce()
expect(resetModelForMigration).toHaveBeenCalledWith(1, 'pty-1')
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
sourceUnits: 0,
bytes: 0,
migratingPtys: 1
})
expect(vi.getTimerCount()).toBe(0)
harness.completions[0]!.resolve()
await Promise.resolve()
expect(resetModelForMigration).toHaveBeenCalledOnce()
harness.intake.closeGeneration(1, 'connection_lost')
expect(harness.intake.getDebugSnapshot().model.migratingPtys).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('contains a running callback failure to its migrating PTY', async () => {
vi.useFakeTimers()
try {
const resetModelForMigration = vi.fn()
const harness = createHarness({ resetModelForMigration })
const sibling = harness.intake.acceptData(
event({
id: 'pty-sibling',
ptyIncarnation: 'incarnation-sibling',
source: {
spanId: 'span-sibling',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'delivery-sibling',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
harness.completions[0]!.resolve()
await sibling
const failed = harness.intake.acceptData(
event({
id: 'pty-failed',
ptyIncarnation: 'incarnation-failed',
source: {
spanId: 'span-failed',
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'delivery-failed',
sourceStartSu: 0,
sourceEndSu: 4
}
})
)
const migration = harness.intake.beginGenerationMigration(1)
harness.completions[1]!.reject(new Error('emulator failed'))
await expect(failed).rejects.toMatchObject({
message: 'emulator failed',
code: 'ssh_model_migration_completion_failed'
})
await expect(migration.byPty.get('pty-failed')).resolves.toEqual({
status: 'checkpoint-unavailable',
reason: 'completion-failed'
})
await expect(migration.byPty.get('pty-sibling')).resolves.toMatchObject({
status: 'settled',
checkpoint: { id: 'pty-sibling', acceptedSourceEndSu: 4 }
})
await migration.completion
expect(resetModelForMigration).toHaveBeenCalledTimes(1)
expect(resetModelForMigration).toHaveBeenCalledWith(1, 'pty-failed')
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
expect(harness.intake.getAcceptedSourceCheckpoints(1)).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'pty-failed', acceptedSourceEndSu: 0 }),
expect.objectContaining({ id: 'pty-sibling', acceptedSourceEndSu: 4 })
])
)
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
sourceUnits: 0,
bytes: 0,
pressureFrames: 0,
migratingPtys: 2
})
expect(vi.getTimerCount()).toBe(0)
harness.completions[1]!.resolve()
await Promise.resolve()
expect(resetModelForMigration).toHaveBeenCalledTimes(1)
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
expect(
harness.intake
.getAcceptedSourceCheckpoints(1)
.find((checkpoint) => checkpoint.id === 'pty-failed')?.acceptedSourceEndSu
).toBe(0)
const unrelated = harness.intake.acceptData(
event({
id: 'pty-unrelated',
providerGeneration: 2,
ptyIncarnation: 'incarnation-unrelated'
})
)
harness.completions[2]!.resolve()
await expect(unrelated).resolves.toMatchObject({ providerGeneration: 2 })
expect(harness.dependencies.closeProvider).not.toHaveBeenCalled()
harness.intake.closeGeneration(1, 'connection_lost')
harness.intake.closeGeneration(2, 'connection_lost')
expect(harness.intake.getDebugSnapshot().model).toMatchObject({
sourceUnits: 0,
bytes: 0,
migratingPtys: 0
})
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,134 @@
import type {
SshPtyAcceptedSourceCheckpoint,
SshPtyOutputSourceObligations
} from './ssh-pty-output-source-obligations'
import type { SshPtyModelAdmissionKey } from './ssh-pty-model-admission-contract'
import type { SshPtyModelAdmission } from './ssh-pty-model-admission'
export const SSH_PTY_MODEL_MIGRATION_TIMEOUT_MS = 10_000
export type SshPtyOutputMigrationResult =
| Readonly<{ status: 'settled'; checkpoint: SshPtyAcceptedSourceCheckpoint }>
| Readonly<{ status: 'checkpoint-unavailable'; reason: 'completion-failed' | 'timeout' }>
export type SshPtyOutputGenerationMigration = Readonly<{
byPty: ReadonlyMap<string, Promise<SshPtyOutputMigrationResult>>
completion: Promise<void>
}>
export type SshPtyTrackedModelAdmission = {
readonly key: SshPtyModelAdmissionKey
started: boolean
completion?: Promise<unknown>
}
export class SshPtyOutputModelMigration {
private readonly pendingByPty = new Map<string, Set<SshPtyTrackedModelAdmission>>()
constructor(
private readonly admission: SshPtyModelAdmission,
private readonly sourceObligations: SshPtyOutputSourceObligations,
private readonly resetModel: (providerGeneration: number, ptyId: string) => void
) {}
track(record: SshPtyTrackedModelAdmission): void {
const id = migrationKey(record.key)
const records = this.pendingByPty.get(id) ?? new Set<SshPtyTrackedModelAdmission>()
records.add(record)
this.pendingByPty.set(id, records)
record.completion?.then(
() => this.remove(id, record),
() => this.remove(id, record)
)
}
beginGeneration(
providerGeneration: number,
timeoutMs = SSH_PTY_MODEL_MIGRATION_TIMEOUT_MS
): SshPtyOutputGenerationMigration {
const checkpoints = this.sourceObligations.acceptedCheckpoints(providerGeneration)
const keys = checkpoints.map((checkpoint) => ({
ptyId: checkpoint.id,
providerGeneration
}))
for (const key of keys) {
this.admission.beginMigration(key)
}
const byPty = new Map<string, Promise<SshPtyOutputMigrationResult>>()
for (const key of keys) {
byPty.set(key.ptyId, this.settlePty(key, timeoutMs))
}
const completion = Promise.allSettled(byPty.values()).then(() => {})
return Object.freeze({ byPty, completion })
}
private async settlePty(
key: SshPtyModelAdmissionKey,
timeoutMs: number
): Promise<SshPtyOutputMigrationResult> {
const running = Array.from(this.pendingByPty.get(migrationKey(key)) ?? []).find(
(record) => record.started
)
if (!running?.completion) {
return this.settledCheckpoint(key)
}
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), normalizedTimeout(timeoutMs))
timer.unref?.()
})
try {
const outcome = await Promise.race([
running.completion.then(
() => 'settled' as const,
() => 'failed' as const
),
timeout
])
if (outcome === 'settled') {
return this.settledCheckpoint(key)
}
const reason = outcome === 'timeout' ? 'timeout' : 'completion-failed'
this.resetModel(key.providerGeneration, key.ptyId)
this.admission.cancelPty(key, `ssh_model_migration_${reason}`)
if (outcome === 'timeout') {
await running.completion.catch(() => {})
}
return Object.freeze({ status: 'checkpoint-unavailable', reason })
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
private settledCheckpoint(key: SshPtyModelAdmissionKey): SshPtyOutputMigrationResult {
const checkpoint = this.sourceObligations.acceptedCheckpoint(key)
if (!checkpoint) {
this.resetModel(key.providerGeneration, key.ptyId)
return Object.freeze({
status: 'checkpoint-unavailable',
reason: 'completion-failed'
})
}
return Object.freeze({ status: 'settled', checkpoint })
}
private remove(id: string, record: SshPtyTrackedModelAdmission): void {
const records = this.pendingByPty.get(id)
records?.delete(record)
if (records?.size === 0) {
this.pendingByPty.delete(id)
}
}
}
function migrationKey(key: SshPtyModelAdmissionKey): string {
return `${key.providerGeneration}\0${key.ptyId}`
}
function normalizedTimeout(timeoutMs: number): number {
return Number.isFinite(timeoutMs) && timeoutMs >= 0
? Math.floor(timeoutMs)
: SSH_PTY_MODEL_MIGRATION_TIMEOUT_MS
}
@@ -0,0 +1,297 @@
import {
ptySourceDeliveryKey,
samePtySourceDelivery,
type PtySourceDeliveryIdentity,
type PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import type { RemoteTerminalSourceRangeConsumerHooks } from '../runtime/remote-terminal-source-range-consumer'
import type { DesktopProjectionSpan } from './ssh-pty-legacy-projection'
import type {
SshPtyOutputDataEvent,
SshPtyOutputExitEvent,
SshPtyOutputIntakeDependencies,
SshPtySourceCancellationProof,
SshPtySourceCancellationRequest
} from './ssh-pty-output-intake-contract'
import { SshPtyRemoteSourceRangeConsumers } from './ssh-pty-remote-source-range-consumers'
import type { SshPtySourceAdmissionReservation } from './ssh-pty-source-obligation-contract'
import { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator'
export type SshPtyOutputSourceReservation = Readonly<{
admission: SshPtySourceAdmissionReservation
span: PtySourceSpan
}>
export type SshPtySourceCancellationProofCommit = Readonly<{
identity: PtySourceDeliveryIdentity
proof: SshPtySourceCancellationProof
}>
export type SshPtyAcceptedSourceCheckpoint = Readonly<{
id: string
providerGeneration: number
clientGeneration: number
ownerGeneration: number
ptyIncarnation: string
deliveryToken: string
acceptedSourceEndSu: number
}>
export class SshPtyOutputSourceObligations {
private readonly coordinator: SshPtySourceObligationCoordinator
private readonly remoteConsumers: SshPtyRemoteSourceRangeConsumers
private readonly openedTokens = new Set<string>()
private readonly identityByPty = new Map<string, PtySourceDeliveryIdentity>()
constructor(publish: SshPtyOutputDataEventPublisher | undefined) {
this.coordinator = new SshPtySourceObligationCoordinator({
onTokenClosed: (identity) => this.removeIdentity(identity),
publish:
publish ??
((_providerGeneration, _batch, onSettled) =>
onSettled({ ok: false, error: new Error('SSH PTY source ACK publisher unavailable') }))
})
this.remoteConsumers = new SshPtyRemoteSourceRangeConsumers(this.coordinator)
}
get remoteHooks(): RemoteTerminalSourceRangeConsumerHooks {
return this.remoteConsumers.hooks
}
reserve(
event: SshPtyOutputDataEvent,
projection: DesktopProjectionSpan
): SshPtyOutputSourceReservation {
const span = this.toSourceSpan(event, projection)
const identity = this.sourceIdentity(span)
const tokenKey = ptySourceDeliveryKey(identity)
if (!this.openedTokens.has(tokenKey)) {
this.coordinator.open(identity, span.sourceStartSu)
this.openedTokens.add(tokenKey)
this.identityByPty.set(this.ptyKey(event), identity)
}
return Object.freeze({
span,
admission: this.coordinator.reserve(identity, span, [
'model',
'desktop',
...this.remoteConsumers.requiredConsumers(event.id)
])
})
}
commit(
reservation: SshPtyOutputSourceReservation,
ptyId: string,
modelSequenceEnd: number
): void {
this.coordinator.commit(reservation.admission)
this.remoteConsumers.trackSpan(
ptyId,
reservation.span.spanId,
reservation.admission.requiredConsumers,
modelSequenceEnd
)
}
rollback(reservation: SshPtyOutputSourceReservation): boolean {
return (
this.coordinator.rollback(reservation.admission) ||
this.coordinator.rollbackCommitted(reservation.admission)
)
}
settleModel(span: PtySourceSpan): void {
this.coordinator.settle({
identity: span,
spanId: span.spanId,
consumer: 'model',
reason: 'model-accepted'
})
}
settleDesktop(span: DesktopProjectionSpan, reason: string): void {
this.coordinator.settle({
identity: span,
spanId: span.spanId,
consumer: 'desktop',
reason
})
}
transferDesktop(span: DesktopProjectionSpan, reason: string): void {
const transition = {
identity: span,
spanId: span.spanId,
consumer: 'desktop' as const,
reason
}
if (this.coordinator.beginTransfer(transition, 'model')) {
this.coordinator.commitTransfer(transition)
}
}
sealPty(event: SshPtyOutputExitEvent): void {
const identity = this.identityByPty.get(this.ptyKey(event))
if (identity) {
this.coordinator.seal(identity)
}
}
markExitPublished(event: SshPtyOutputExitEvent): void {
const identity = this.identityByPty.get(this.ptyKey(event))
if (identity) {
this.coordinator.markExitPublished(identity)
}
}
whenPtyTerminal(event: SshPtyOutputExitEvent): Promise<void> {
const identity = this.identityByPty.get(this.ptyKey(event))
return identity ? this.coordinator.whenTerminal(identity) : Promise.resolve()
}
async requestPtyCancellationProof(
event: SshPtyOutputExitEvent,
cancel: (request: SshPtySourceCancellationRequest) => Promise<SshPtySourceCancellationProof>
): Promise<SshPtySourceCancellationProofCommit | null> {
const identity = this.identityByPty.get(this.ptyKey(event))
if (!identity) {
return null
}
const request = this.coordinator.beginExitTimeout(identity)
const proof = await cancel(request)
return Object.freeze({ identity, proof })
}
commitPtyCancellationProof(commit: SshPtySourceCancellationProofCommit): void {
this.coordinator.applyCancellationProof(commit.identity, commit.proof)
}
applyCancellationProof(
event: SshPtyOutputExitEvent,
proof: SshPtySourceCancellationProof
): boolean {
const identity = this.identityByPty.get(this.ptyKey(event))
if (!identity) {
return false
}
this.coordinator.applyCancellationProof(identity, proof)
return true
}
applyRecoveryCancellationProof(
event: SshPtyOutputExitEvent,
proof: SshPtySourceCancellationProof
): void {
const identity = this.identityByPty.get(this.ptyKey(event))
if (identity) {
this.coordinator.applyRecoveryCancellationProof(identity, proof)
}
}
closeGeneration(providerGeneration: number, reason: string): void {
this.remoteConsumers.closeGeneration(providerGeneration, reason)
this.coordinator.closeGeneration(providerGeneration, reason)
const prefix = `${providerGeneration}\0`
for (const key of this.openedTokens) {
if (key.startsWith(prefix)) {
this.openedTokens.delete(key)
}
}
for (const [key, identity] of this.identityByPty) {
if (identity.providerGeneration === providerGeneration) {
this.identityByPty.delete(key)
}
}
}
acceptedCheckpoints(providerGeneration: number): readonly SshPtyAcceptedSourceCheckpoint[] {
const checkpoints: SshPtyAcceptedSourceCheckpoint[] = []
for (const identity of this.identityByPty.values()) {
if (identity.providerGeneration !== providerGeneration) {
continue
}
checkpoints.push(
Object.freeze({
id: identity.id,
providerGeneration: identity.providerGeneration,
clientGeneration: identity.clientGeneration,
ownerGeneration: identity.ownerGeneration,
ptyIncarnation: identity.ptyIncarnation,
deliveryToken: identity.deliveryToken,
acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(identity)
})
)
}
return Object.freeze(checkpoints)
}
acceptedCheckpoint(key: {
ptyId: string
providerGeneration: number
}): SshPtyAcceptedSourceCheckpoint | null {
return (
this.acceptedCheckpoints(key.providerGeneration).find(
(checkpoint) => checkpoint.id === key.ptyId
) ?? null
)
}
dispose(): void {
this.coordinator.dispose()
}
getDebugSnapshot(): Readonly<{ openedTokens: number; ptyIdentities: number }> {
return Object.freeze({
openedTokens: this.openedTokens.size,
ptyIdentities: this.identityByPty.size
})
}
private toSourceSpan(
event: SshPtyOutputDataEvent,
projection: DesktopProjectionSpan
): PtySourceSpan {
return Object.freeze({
id: projection.id,
providerGeneration: event.providerGeneration,
clientGeneration: projection.clientGeneration,
ownerGeneration: projection.ownerGeneration,
ptyIncarnation: event.ptyIncarnation,
deliveryToken: projection.deliveryToken,
spanId: projection.spanId,
sourceStartSu: projection.sourceStartSu,
sourceEndSu: projection.sourceEndSu,
displayStart: projection.displayStart,
displayEnd: projection.displayEnd,
splittable: projection.splittable,
transform: projection.transform,
data: event.data
})
}
private sourceIdentity(source: PtySourceSpan): PtySourceDeliveryIdentity {
return source
}
private ptyKey(event: {
id: string
providerGeneration: number
ptyIncarnation: string
}): string {
return `${event.providerGeneration}\0${event.id}\0${event.ptyIncarnation}`
}
private removeIdentity(identity: PtySourceDeliveryIdentity): void {
this.openedTokens.delete(ptySourceDeliveryKey(identity))
for (const [key, candidate] of this.identityByPty) {
if (samePtySourceDelivery(candidate, identity)) {
this.identityByPty.delete(key)
}
}
}
}
type SshPtyOutputDataEventPublisher = NonNullable<
SshPtyOutputIntakeDependencies['publishSourceAck']
>
@@ -0,0 +1,112 @@
import type { ProjectionRecord } from './ssh-pty-legacy-projection-record'
type ProjectionTerminalWaiter = {
providerGeneration: number
ptyIncarnation: string
resolve: () => void
}
function matchesProjection(
record: ProjectionRecord | undefined,
providerGeneration: number,
ptyIncarnation: string
): boolean {
const identity = record?.semantics.identity
return (
identity?.providerGeneration === providerGeneration &&
identity.ptyIncarnation === ptyIncarnation
)
}
export function unpublishedProjectionIds(
records: ReadonlyMap<string, ProjectionRecord>,
ids: readonly string[],
providerGeneration: number,
ptyIncarnation: string
): string[] {
return ids.filter((id) => {
const record = records.get(id)
return (
record?.state === 'committed' && matchesProjection(record, providerGeneration, ptyIncarnation)
)
})
}
export function projectionHasOpen(
records: ReadonlyMap<string, ProjectionRecord>,
idsByPty: ReadonlyMap<string, readonly string[]>,
ptyId: string
): (providerGeneration: number, ptyIncarnation: string) => boolean {
return (providerGeneration, ptyIncarnation) =>
(idsByPty.get(ptyId) ?? []).some((id) =>
matchesProjection(records.get(id), providerGeneration, ptyIncarnation)
)
}
export function resolveProjectionTerminality(
terminality: SshPtyProjectionTerminality,
records: ReadonlyMap<string, ProjectionRecord>,
idsByPty: ReadonlyMap<string, readonly string[]>,
ptyId: string
): void {
terminality.resolve(ptyId, projectionHasOpen(records, idsByPty, ptyId))
}
export class SshPtyProjectionTerminality {
private readonly waitersByPty = new Map<string, ProjectionTerminalWaiter[]>()
whenTerminal(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
hasOpen: (providerGeneration: number, ptyIncarnation: string) => boolean
): Promise<void> {
if (!hasOpen(providerGeneration, ptyIncarnation)) {
return Promise.resolve()
}
return new Promise((resolve) => {
const waiters = this.waitersByPty.get(ptyId) ?? []
waiters.push({ providerGeneration, ptyIncarnation, resolve })
this.waitersByPty.set(ptyId, waiters)
})
}
resolve(
ptyId: string,
hasOpen: (providerGeneration: number, ptyIncarnation: string) => boolean
): void {
const waiters = this.waitersByPty.get(ptyId)
if (!waiters) {
return
}
const pending = waiters.filter((waiter) =>
hasOpen(waiter.providerGeneration, waiter.ptyIncarnation)
)
for (const waiter of waiters) {
if (!pending.includes(waiter)) {
waiter.resolve()
}
}
if (pending.length > 0) {
this.waitersByPty.set(ptyId, pending)
} else {
this.waitersByPty.delete(ptyId)
}
}
closeGeneration(providerGeneration: number): void {
for (const [ptyId, waiters] of this.waitersByPty) {
const pending = waiters.filter((waiter) => waiter.providerGeneration !== providerGeneration)
for (const waiter of waiters) {
if (waiter.providerGeneration === providerGeneration) {
waiter.resolve()
}
}
if (pending.length > 0) {
this.waitersByPty.set(ptyId, pending)
} else {
this.waitersByPty.delete(ptyId)
}
}
}
}
@@ -0,0 +1,451 @@
import { describe, expect, it, vi } from 'vitest'
import type {
PtySourceDeliveryIdentity,
PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
import { SshPtyRemoteSourceRangeConsumers } from './ssh-pty-remote-source-range-consumers'
import { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator'
const identity: PtySourceDeliveryIdentity = {
id: 'pty-1',
providerGeneration: 1,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1'
}
function span(spanId: string, overrides: Partial<PtySourceSpan> = {}): PtySourceSpan {
return {
...identity,
spanId,
sourceStartSu: 0,
sourceEndSu: 4,
displayStart: 0,
displayEnd: 4,
data: 'data',
splittable: true,
transform: { transformed: false, rawLengthSu: 4, scalarSafe: true },
...overrides
}
}
function range(
spanId: string,
overrides: Partial<TerminalOutputSourceRange> = {}
): TerminalOutputSourceRange {
return {
id: 'pty-1',
spanId,
providerGeneration: 1,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4,
displayStart: 0,
displayEnd: 4,
splittable: true,
transform: { transformed: false, rawLengthSu: 4, scalarSafe: true },
...overrides
}
}
function createCoordinator(): SshPtySourceObligationCoordinator {
return new SshPtySourceObligationCoordinator({
publish: vi.fn(),
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
}
describe('SshPtyRemoteSourceRangeConsumers', () => {
it('snapshots membership and settles only the current stream generation', () => {
const ledger = createCoordinator()
const progress = vi.fn()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger, progress)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
expect(consumers.hooks.attach(stream)).toBe(true)
const sourceSpan = span('span-1')
const reservation = ledger.reserve(identity, sourceSpan, [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', sourceSpan.spanId, reservation.requiredConsumers, 4)
consumers.hooks.settle({ ...stream, streamGeneration: 'stale' }, [range('span-1')])
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('open')
consumers.hooks.settle(stream, [range('span-1')])
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('settled')
expect(progress).toHaveBeenCalledTimes(1)
})
it.each(['headless', 'renderer'] as const)(
'commits remaining mappings only after a %s snapshot publication',
(source) => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
const replacement = consumers.hooks.reserveReplacement(stream, 4, 'initial-snapshot')
expect(replacement).not.toBeNull()
expect(ledger.obligation('span-1', 'remote:consumer-1')).toMatchObject({
state: 'transferring',
to: 'remote:snapshot:consumer-1'
})
expect(consumers.hooks.commitReplacement(replacement!, { source, seq: 3 })).toBe(false)
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('transferring')
expect(consumers.hooks.commitReplacement(replacement!, { source, seq: 4 })).toBe(true)
expect(ledger.obligation('span-1', 'remote:consumer-1')).toMatchObject({
state: 'transferred',
to: 'remote:snapshot:consumer-1'
})
expect(consumers.requiredConsumers('pty-1')).toEqual(['remote:consumer-1'])
}
)
it('reserves only spans covered by the authoritative snapshot sequence', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const covered = ledger.reserve(identity, span('span-covered'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(covered)
consumers.trackSpan('pty-1', 'span-covered', covered.requiredConsumers, 4)
const trailingSpan = {
...span('span-trailing'),
sourceStartSu: 4,
sourceEndSu: 8,
displayStart: 4,
displayEnd: 8
}
const trailing = ledger.reserve(identity, trailingSpan, [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(trailing)
consumers.trackSpan('pty-1', 'span-trailing', trailing.requiredConsumers, 8)
const replacement = consumers.hooks.reserveReplacement(stream, 4, 'initial-snapshot')
expect(replacement).not.toBeNull()
expect(ledger.obligation('span-covered', 'remote:consumer-1')).toMatchObject({
state: 'transferring'
})
expect(ledger.obligation('span-trailing', 'remote:consumer-1')).toMatchObject({
state: 'open'
})
})
it('rolls a failed replacement publication back to the live stream obligation', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
const replacement = consumers.hooks.reserveReplacement(stream, 4, 'initial-snapshot')
expect(consumers.hooks.rollbackReplacement(replacement!, 'snapshot-write-failed')).toBe(true)
expect(ledger.obligation('span-1', 'remote:consumer-1')).toMatchObject({
state: 'open'
})
})
it('rolls back every transfer when replacement reservation fails partway', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const first = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(first)
consumers.trackSpan('pty-1', 'span-1', first.requiredConsumers, 4)
const secondSpan = span('span-2', {
sourceStartSu: 4,
sourceEndSu: 8,
displayStart: 4,
displayEnd: 8
})
const second = ledger.reserve(identity, secondSpan, [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(second)
consumers.trackSpan('pty-1', 'span-2', second.requiredConsumers, 8)
const beginTransfer = ledger.beginTransfer.bind(ledger)
vi.spyOn(ledger, 'beginTransfer').mockImplementation((transition, replacement) => {
if (transition.spanId === 'span-2') {
throw new Error('injected partial reserve failure')
}
return beginTransfer(transition, replacement)
})
expect(() => consumers.hooks.reserveReplacement(stream, 8, 'initial-snapshot')).toThrowError(
'injected partial reserve failure'
)
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('open')
expect(ledger.obligation('span-2', 'remote:consumer-1').state).toBe('open')
})
it('rejects a replacement whose exact transfer state changed before commit', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const sourceSpan = span('span-1')
const admission = ledger.reserve(identity, sourceSpan, [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(admission)
consumers.trackSpan('pty-1', 'span-1', admission.requiredConsumers, 4)
const replacement = consumers.hooks.reserveReplacement(stream, 4, 'initial-snapshot')!
const transition = {
identity: sourceSpan,
spanId: sourceSpan.spanId,
consumer: 'remote:consumer-1' as const,
reason: 'concurrent-replacement'
}
expect(ledger.rollbackTransfer(transition)).toBe(true)
expect(ledger.beginTransfer(transition, 'remote:snapshot:consumer-1')).toBe(true)
expect(consumers.hooks.commitReplacement(replacement, { source: 'headless', seq: 4 })).toBe(
false
)
expect(consumers.hooks.rollbackReplacement(replacement, 'commit-rejected')).toBe(false)
expect(ledger.obligation('span-1', 'remote:consumer-1')).toMatchObject({
state: 'transferring',
reason: 'concurrent-replacement'
})
})
it('settles a split source span only after its complete ordered range is acknowledged', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
consumers.hooks.settle(stream, [
range('span-1', {
sourceEndSu: 2,
displayEnd: 2,
transform: { transformed: false, rawLengthSu: 2, scalarSafe: true }
})
])
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('open')
consumers.hooks.settle(stream, [
range('span-1', {
sourceStartSu: 2,
sourceEndSu: 4,
displayStart: 2,
displayEnd: 4,
transform: { transformed: false, rawLengthSu: 2, scalarSafe: true }
})
])
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('settled')
})
it('cancels an admitted span on detach without minting a snapshot owner', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
consumers.hooks.cancel(stream, [], 'stream-detached')
expect(ledger.obligation('span-1', 'remote:consumer-1')).toMatchObject({
state: 'canceled',
reason: 'stream-detached'
})
expect(consumers.requiredConsumers('pty-1')).toEqual([])
})
it('rolls back a pending replacement before disconnect cancellation', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
const replacement = consumers.hooks.reserveReplacement(stream, 4, 'initial-snapshot')
consumers.hooks.cancel(stream, [], 'connection-closed')
expect(consumers.hooks.commitReplacement(replacement!, { source: 'headless', seq: 4 })).toBe(
false
)
expect(ledger.obligation('span-1', 'remote:consumer-1')).toMatchObject({
state: 'canceled',
reason: 'connection-closed'
})
})
it('rejects stale stream generations without changing the current obligation', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
expect(() =>
consumers.hooks.reserveReplacement(
{ ...stream, streamGeneration: 'stale' },
4,
'initial-snapshot'
)
).toThrow('stale')
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('open')
})
it('rolls back replacement admission before provider-generation close', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
const replacement = consumers.hooks.reserveReplacement(stream, 4, 'initial-snapshot')
consumers.closeGeneration(identity.providerGeneration, 'provider-replaced')
expect(ledger.obligation('span-1', 'remote:consumer-1').state).toBe('open')
expect(consumers.hooks.commitReplacement(replacement!, { source: 'headless', seq: 4 })).toBe(
false
)
})
it('detaches cleanly after cancellation proof reclaims tracked spans', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
ledger.seal(identity)
ledger.beginExitTimeout(identity)
ledger.applyCancellationProof(identity, { sentEndSu: 4, creditedEndSu: 0 })
expect(() => consumers.hooks.settle(stream, [range('span-1')])).not.toThrow()
expect(() => consumers.hooks.cancel(stream, [], 'stream-detached')).not.toThrow()
expect(consumers.requiredConsumers('pty-1')).toEqual([])
})
it('rejects and rolls back a replacement after proof reclaims its covered spans', () => {
const ledger = createCoordinator()
const consumers = new SshPtyRemoteSourceRangeConsumers(ledger)
const stream = { ptyId: 'pty-1', consumerId: 'consumer-1', streamGeneration: 'stream-1' }
ledger.open(identity)
consumers.hooks.attach(stream)
const reservation = ledger.reserve(identity, span('span-1'), [
'model',
...consumers.requiredConsumers('pty-1')
])
ledger.commit(reservation)
consumers.trackSpan('pty-1', 'span-1', reservation.requiredConsumers, 4)
const recoveredIdentity = {
...identity,
clientGeneration: 3,
ownerGeneration: 4,
deliveryToken: 'token-2'
}
ledger.open(recoveredIdentity, 4)
const recovered = ledger.reserve(
recoveredIdentity,
span('span-2', {
...recoveredIdentity,
sourceStartSu: 4,
sourceEndSu: 8,
displayStart: 4,
displayEnd: 8
}),
['model', ...consumers.requiredConsumers('pty-1')]
)
ledger.commit(recovered)
consumers.trackSpan('pty-1', 'span-2', recovered.requiredConsumers, 8)
const replacement = consumers.hooks.reserveReplacement(stream, 8, 'initial-snapshot')
ledger.seal(identity)
ledger.beginExitTimeout(identity)
ledger.applyCancellationProof(identity, { sentEndSu: 4, creditedEndSu: 0 })
const queuedOutput = ['covered-output', 'surviving-output']
const committed = consumers.hooks.commitReplacement(replacement!, {
source: 'headless',
seq: 8
})
if (committed) {
queuedOutput.splice(0)
}
expect(committed).toBe(false)
expect(queuedOutput).toEqual(['covered-output', 'surviving-output'])
expect(consumers.hooks.rollbackReplacement(replacement!, 'commit-rejected')).toBe(true)
expect(ledger.obligation('span-2', 'remote:consumer-1').state).toBe('open')
expect(consumers.hooks.rollbackReplacement(replacement!, 'commit-rejected')).toBe(false)
expect(() => consumers.hooks.cancel(stream, [], 'stream-detached')).not.toThrow()
expect(consumers.requiredConsumers('pty-1')).toEqual([])
})
})
@@ -0,0 +1,285 @@
import {
sameTerminalOutputSourceIdentity,
type TerminalOutputSourceRange
} from '../../shared/terminal-output-source-range'
import type {
RemoteTerminalSourceRangeConsumerHooks,
RemoteTerminalSourceRangeStreamIdentity
} from '../runtime/remote-terminal-source-range-consumer'
import type { PtySourceSpan } from '../../shared/pty-source-credit-contract'
import type { SshPtySourceConsumerId } from './ssh-pty-source-obligation-contract'
import type { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator'
import { SshPtyRemoteSourceRangeReplacements } from './ssh-pty-remote-source-range-replacement'
function remoteConsumerId(
identity: RemoteTerminalSourceRangeStreamIdentity
): SshPtySourceConsumerId {
return `remote:${identity.consumerId}`
}
function uniqueSpanIds(ranges: readonly TerminalOutputSourceRange[]): string[] {
return Array.from(new Set(ranges.map((range) => range.spanId)))
}
type RemoteConsumerState = {
streamGeneration: string
spans: Map<
string,
Readonly<{
identity: PtySourceSpan
modelSequenceEnd: number
}>
>
ackedEndBySpan: Map<string, number>
}
export class SshPtyRemoteSourceRangeConsumers {
private readonly consumersByPty = new Map<string, Map<string, RemoteConsumerState>>()
private readonly replacements: SshPtyRemoteSourceRangeReplacements
constructor(
private readonly coordinator: SshPtySourceObligationCoordinator,
private readonly onProgress: (range: TerminalOutputSourceRange) => void = () => {}
) {
this.replacements = new SshPtyRemoteSourceRangeReplacements(coordinator)
}
readonly hooks: RemoteTerminalSourceRangeConsumerHooks = {
attach: (identity) => this.attach(identity),
settle: (identity, ranges) => this.settle(identity, ranges),
reserveReplacement: (identity, requiredSeq, reason) =>
this.reserveReplacement(identity, requiredSeq, reason),
commitReplacement: (reservation, publication) =>
this.commitReplacement(reservation, publication),
rollbackReplacement: (reservation, reason) => this.rollbackReplacement(reservation, reason),
cancel: (identity, ranges, reason) => this.cancel(identity, ranges, reason)
}
requiredConsumers(ptyId: string): readonly SshPtySourceConsumerId[] {
return Object.freeze(
Array.from(this.consumersByPty.get(ptyId)?.keys() ?? []).map(
(consumerId) => `remote:${consumerId}` as const
)
)
}
trackSpan(
ptyId: string,
spanId: string,
requiredConsumers: readonly SshPtySourceConsumerId[],
modelSequenceEnd: number
): void {
if (!Number.isSafeInteger(modelSequenceEnd) || modelSequenceEnd < 0) {
throw new Error('ssh_remote_source_range_model_sequence_invalid')
}
for (const [consumerId, state] of this.consumersByPty.get(ptyId) ?? []) {
if (requiredConsumers.includes(`remote:${consumerId}`)) {
state.spans.set(
spanId,
Object.freeze({
identity: this.coordinator.spanIdentity(spanId),
modelSequenceEnd
})
)
}
}
}
closeGeneration(providerGeneration: number, reason: string): void {
this.replacements.closeGeneration(providerGeneration, reason)
for (const consumers of this.consumersByPty.values()) {
for (const state of consumers.values()) {
for (const [spanId, tracked] of state.spans) {
if (tracked.identity.providerGeneration === providerGeneration) {
state.spans.delete(spanId)
state.ackedEndBySpan.delete(spanId)
}
}
}
}
}
private attach(identity: RemoteTerminalSourceRangeStreamIdentity): boolean {
const consumers =
this.consumersByPty.get(identity.ptyId) ?? new Map<string, RemoteConsumerState>()
const current = consumers.get(identity.consumerId)
if (current && current.streamGeneration !== identity.streamGeneration) {
return false
}
consumers.set(
identity.consumerId,
current ?? {
streamGeneration: identity.streamGeneration,
spans: new Map(),
ackedEndBySpan: new Map()
}
)
this.consumersByPty.set(identity.ptyId, consumers)
return true
}
private settle(
identity: RemoteTerminalSourceRangeStreamIdentity,
ranges: readonly TerminalOutputSourceRange[]
): void {
if (!this.isCurrent(identity)) {
return
}
const state = this.requireState(identity)
const consumer = remoteConsumerId(identity)
const nextEnds = new Map(state.ackedEndBySpan)
const completed = new Set<string>()
for (const range of ranges) {
const tracked = state.spans.get(range.spanId)
if (!tracked) {
continue
}
const source = tracked.identity
if (!this.coordinator.hasRetainedSpan(range.spanId)) {
state.spans.delete(range.spanId)
nextEnds.delete(range.spanId)
continue
}
const currentEnd = nextEnds.get(range.spanId) ?? source.sourceStartSu
if (
!sameTerminalOutputSourceIdentity(source, range) ||
range.sourceStartSu !== currentEnd ||
range.sourceEndSu > source.sourceEndSu
) {
throw new Error('ssh_remote_source_range_settlement_invalid')
}
nextEnds.set(range.spanId, range.sourceEndSu)
if (range.sourceEndSu === source.sourceEndSu) {
completed.add(range.spanId)
}
}
state.ackedEndBySpan = nextEnds
for (const spanId of completed) {
const tracked = state.spans.get(spanId)
if (!tracked || !this.coordinator.hasRetainedSpan(spanId)) {
state.spans.delete(spanId)
state.ackedEndBySpan.delete(spanId)
continue
}
const source = tracked.identity
this.coordinator.settle({
identity: source,
spanId,
consumer,
reason: 'remote-frame-ack'
})
state.spans.delete(spanId)
state.ackedEndBySpan.delete(spanId)
}
for (const range of ranges) {
this.onProgress(range)
}
}
private reserveReplacement(
identity: RemoteTerminalSourceRangeStreamIdentity,
requiredSeq: number,
reason: string
): ReturnType<SshPtyRemoteSourceRangeReplacements['reserve']> {
if (!this.isCurrent(identity)) {
throw new Error('ssh_remote_source_range_stale_generation')
}
const state = this.requireState(identity)
for (const spanId of state.spans.keys()) {
if (!this.coordinator.hasRetainedSpan(spanId)) {
state.spans.delete(spanId)
state.ackedEndBySpan.delete(spanId)
}
}
const spanIds = Array.from(state.spans)
.filter(([, tracked]) => tracked.modelSequenceEnd <= requiredSeq)
.map(([spanId]) => spanId)
return this.replacements.reserve(identity, spanIds, requiredSeq, reason)
}
private commitReplacement(
reservation: Parameters<SshPtyRemoteSourceRangeReplacements['commit']>[0],
publication: Parameters<SshPtyRemoteSourceRangeReplacements['commit']>[1]
): boolean {
return this.replacements.commit(
reservation,
publication,
this.isCurrent(reservation.identity),
(spanIds) => {
const state = this.requireState(reservation.identity)
for (const spanId of spanIds) {
state.spans.delete(spanId)
state.ackedEndBySpan.delete(spanId)
}
}
)
}
private rollbackReplacement(
reservation: Parameters<SshPtyRemoteSourceRangeReplacements['rollback']>[0],
reason: string
): boolean {
return this.replacements.rollback(reservation, reason)
}
private cancel(
identity: RemoteTerminalSourceRangeStreamIdentity,
ranges: readonly TerminalOutputSourceRange[],
reason: string
): void {
if (!this.isCurrent(identity)) {
return
}
this.replacements.rollbackIdentity(identity, `${reason}-replacement-aborted`)
const consumer = remoteConsumerId(identity)
const state = this.requireState(identity)
const spanIds = new Set([
...state.spans.keys(),
...uniqueSpanIds(ranges).filter((spanId) => state.spans.has(spanId))
])
for (const spanId of spanIds) {
const tracked = state.spans.get(spanId)
if (!tracked || !this.coordinator.hasRetainedSpan(spanId)) {
continue
}
const source = tracked.identity
const transition = { identity: source, spanId, consumer, reason }
if (this.coordinator.beginTransfer(transition, consumer)) {
this.coordinator.cancelTransfer(transition)
}
}
this.detachIdentity(identity)
for (const range of ranges) {
this.onProgress(range)
}
}
private isCurrent(identity: RemoteTerminalSourceRangeStreamIdentity): boolean {
return (
this.consumersByPty.get(identity.ptyId)?.get(identity.consumerId)?.streamGeneration ===
identity.streamGeneration
)
}
private requireState(identity: RemoteTerminalSourceRangeStreamIdentity): RemoteConsumerState {
const state = this.consumersByPty.get(identity.ptyId)?.get(identity.consumerId)
if (!state || state.streamGeneration !== identity.streamGeneration) {
throw new Error('ssh_remote_source_range_stale_generation')
}
return state
}
private detachIdentity(identity: RemoteTerminalSourceRangeStreamIdentity): void {
const consumers = this.consumersByPty.get(identity.ptyId)
if (
!consumers ||
consumers.get(identity.consumerId)?.streamGeneration !== identity.streamGeneration
) {
return
}
consumers.delete(identity.consumerId)
if (consumers.size === 0) {
this.consumersByPty.delete(identity.ptyId)
}
}
}
@@ -0,0 +1,194 @@
import type {
RemoteTerminalSourceRangeReplacementPublication,
RemoteTerminalSourceRangeReplacementReservation,
RemoteTerminalSourceRangeStreamIdentity
} from '../runtime/remote-terminal-source-range-consumer'
import type { PtySourceSpan } from '../../shared/pty-source-credit-contract'
import type {
SshPtySourceConsumerId,
SshPtySourceObligationState
} from './ssh-pty-source-obligation-contract'
import type { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator'
type ReplacementSpanRecord = Readonly<{
source: PtySourceSpan
transferState: SshPtySourceObligationState
}>
type ReplacementReservationRecord = {
reservation: RemoteTerminalSourceRangeReplacementReservation
spans: readonly ReplacementSpanRecord[]
consumer: SshPtySourceConsumerId
replacement: SshPtySourceConsumerId
reason: string
}
function remoteConsumerId(
identity: RemoteTerminalSourceRangeStreamIdentity
): SshPtySourceConsumerId {
return `remote:${identity.consumerId}`
}
export class SshPtyRemoteSourceRangeReplacements {
private readonly reservations = new Map<string, ReplacementReservationRecord>()
private nextReservationId = 1
constructor(private readonly coordinator: SshPtySourceObligationCoordinator) {}
reserve(
identity: RemoteTerminalSourceRangeStreamIdentity,
spanIds: readonly string[],
requiredSeq: number,
reason: string
): RemoteTerminalSourceRangeReplacementReservation | null {
if (spanIds.length === 0) {
return null
}
if (!Number.isSafeInteger(requiredSeq) || requiredSeq < 0) {
throw new Error('ssh_remote_source_range_replacement_sequence_invalid')
}
const consumer = remoteConsumerId(identity)
const replacement = `remote:snapshot:${identity.consumerId}` as const
const spans = spanIds.map((spanId) => this.coordinator.spanIdentity(spanId))
for (const { spanId } of spans) {
if (this.coordinator.obligation(spanId, consumer).state !== 'open') {
throw new Error('ssh_remote_source_range_transfer_invalid')
}
}
const transferred: ReplacementSpanRecord[] = []
try {
for (const source of spans) {
const { spanId } = source
const transition = { identity: source, spanId, consumer, reason }
if (!this.coordinator.beginTransfer(transition, replacement)) {
throw new Error('ssh_remote_source_range_transfer_invalid')
}
const transferState = this.coordinator.obligation(spanId, consumer)
if (transferState.state !== 'transferring' || transferState.to !== replacement) {
this.coordinator.rollbackTransfer(transition)
throw new Error('ssh_remote_source_range_transfer_invalid')
}
transferred.push(Object.freeze({ source, transferState }))
}
} catch (error) {
for (const span of transferred.toReversed()) {
this.rollbackExactSpan(span, consumer, reason)
}
throw error
}
const reservation = Object.freeze({
reservationId: `remote-source-replacement:${this.nextReservationId++}`,
identity: Object.freeze({ ...identity }),
requiredSeq
})
this.reservations.set(reservation.reservationId, {
reservation,
spans: Object.freeze(transferred),
consumer,
replacement,
reason
})
return reservation
}
commit(
reservation: RemoteTerminalSourceRangeReplacementReservation,
publication: RemoteTerminalSourceRangeReplacementPublication,
isCurrent: boolean,
onCommitted: (spanIds: readonly string[]) => void
): boolean {
const record = this.reservations.get(reservation.reservationId)
if (
!record ||
record.reservation !== reservation ||
!isCurrent ||
!Number.isSafeInteger(publication.seq) ||
publication.seq < reservation.requiredSeq ||
(publication.source !== 'headless' && publication.source !== 'renderer')
) {
return false
}
if (
record.spans.some(({ source, transferState }) => {
const { spanId } = source
if (!this.coordinator.hasRetainedSpan(spanId)) {
return true
}
return this.coordinator.obligation(spanId, record.consumer) !== transferState
})
) {
return false
}
for (const { source } of record.spans) {
const { spanId } = source
if (!this.coordinator.hasRetainedSpan(spanId)) {
continue
}
if (
!this.coordinator.commitTransfer({ identity: source, spanId, consumer: record.consumer })
) {
throw new Error('ssh_remote_source_range_replacement_commit_invalid')
}
}
this.reservations.delete(reservation.reservationId)
onCommitted(record.spans.map(({ source }) => source.spanId))
return true
}
rollback(reservation: RemoteTerminalSourceRangeReplacementReservation, reason: string): boolean {
const record = this.reservations.get(reservation.reservationId)
if (!record || record.reservation !== reservation) {
return false
}
this.reservations.delete(reservation.reservationId)
let rolledBack = true
for (const span of record.spans) {
const { spanId } = span.source
if (!this.coordinator.hasRetainedSpan(spanId)) {
continue
}
rolledBack = this.rollbackExactSpan(span, record.consumer, reason) && rolledBack
}
return rolledBack
}
rollbackIdentity(identity: RemoteTerminalSourceRangeStreamIdentity, reason: string): void {
for (const record of Array.from(this.reservations.values())) {
if (
record.reservation.identity.ptyId === identity.ptyId &&
record.reservation.identity.consumerId === identity.consumerId &&
record.reservation.identity.streamGeneration === identity.streamGeneration
) {
this.rollback(record.reservation, reason)
}
}
}
closeGeneration(providerGeneration: number, reason: string): void {
for (const record of Array.from(this.reservations.values())) {
if (record.spans.some(({ source }) => source.providerGeneration === providerGeneration)) {
this.rollback(record.reservation, `${reason}-replacement-aborted`)
}
}
}
private rollbackExactSpan(
span: ReplacementSpanRecord,
consumer: SshPtySourceConsumerId,
reason: string
): boolean {
const { source, transferState } = span
if (
!this.coordinator.hasRetainedSpan(source.spanId) ||
this.coordinator.obligation(source.spanId, consumer) !== transferState
) {
return false
}
return this.coordinator.rollbackTransfer({
identity: source,
spanId: source.spanId,
consumer,
reason
})
}
}
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from 'vitest'
import type { PtySourceCreditAckBatch } from '../../shared/pty-source-credit-contract'
import { SshPtySourceAckCoalescer } from './ssh-pty-source-ack-coalescer'
function publication(token: number, endSu: number, settled = vi.fn(), providerGeneration = 1) {
const identity = {
id: `pty-${token}`,
providerGeneration,
clientGeneration: 1,
ownerGeneration: 1,
ptyIncarnation: `incarnation-${token}`,
deliveryToken: `token-${token}`
}
return {
identity,
ack: {
id: identity.id,
clientGeneration: 1,
ownerGeneration: 1,
deliveryToken: identity.deliveryToken,
creditedEndSu: endSu
},
onSettled: settled
}
}
describe('SshPtySourceAckCoalescer', () => {
it('coalesces cumulative values and advances them only from the write callback', () => {
const writes: {
batch: PtySourceCreditAckBatch
settle: (result: { ok: true } | { ok: false; error: Error }) => void
}[] = []
const firstSettled = vi.fn()
const latestSettled = vi.fn()
const coalescer = new SshPtySourceAckCoalescer({
publish: (_providerGeneration, batch, settle) => writes.push({ batch, settle }),
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coalescer.enqueue(publication(1, 10, firstSettled))
coalescer.enqueue(publication(1, 20, latestSettled))
coalescer.flush()
expect(writes[0].batch.acknowledgements).toEqual([
expect.objectContaining({ deliveryToken: 'token-1', creditedEndSu: 20 })
])
expect(latestSettled).not.toHaveBeenCalled()
writes[0].settle({ ok: true })
expect(latestSettled).toHaveBeenCalledWith({ ok: true })
expect(firstSettled).toHaveBeenCalledWith({ ok: true })
})
it('limits one batch to 64 tokens and gives the remainder another turn', () => {
const batches: PtySourceCreditAckBatch[] = []
const scheduled: (() => void)[] = []
const coalescer = new SshPtySourceAckCoalescer({
publish: (_providerGeneration, batch, settle) => {
batches.push(batch)
settle({ ok: true })
},
schedule: (callback) => {
scheduled.push(callback)
return scheduled.length as unknown as ReturnType<typeof setTimeout>
},
cancelSchedule: vi.fn()
})
for (let token = 0; token < 70; token++) {
coalescer.enqueue(publication(token, 1))
}
coalescer.flush()
expect(batches[0].acknowledgements).toHaveLength(64)
scheduled.at(-1)!()
expect(batches[1].acknowledgements).toHaveLength(6)
})
it('never mixes provider generations in one transport batch', () => {
const generations: number[] = []
const coalescer = new SshPtySourceAckCoalescer({
publish: (providerGeneration, _batch, settle) => {
generations.push(providerGeneration)
settle({ ok: true })
},
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coalescer.enqueue(publication(1, 1, vi.fn(), 1))
coalescer.enqueue(publication(2, 1, vi.fn(), 2))
coalescer.flush()
coalescer.flush()
expect(generations).toEqual([1, 2])
})
it('settles every entry as failed on a synchronous send error', () => {
const settled = vi.fn()
const coalescer = new SshPtySourceAckCoalescer({
publish: () => {
throw new Error('send failed')
},
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coalescer.enqueue(publication(1, 10, settled))
coalescer.flush()
expect(settled).toHaveBeenCalledWith({
ok: false,
error: expect.objectContaining({ message: 'send failed' })
})
})
it('promotes a pending interval flush to immediate at the source threshold', () => {
const delays: number[] = []
const cancelSchedule = vi.fn()
const coalescer = new SshPtySourceAckCoalescer({
publish: vi.fn(),
schedule: (_callback, delayMs) => {
delays.push(delayMs)
return delays.length as unknown as ReturnType<typeof setTimeout>
},
cancelSchedule
})
coalescer.enqueue(publication(1, 1))
coalescer.enqueue(publication(1, 64 * 1024))
expect(delays).toEqual([8, 0])
expect(cancelSchedule).toHaveBeenCalledOnce()
})
it('fails queued callbacks exactly once during cleanup', () => {
const settled = vi.fn()
const coalescer = new SshPtySourceAckCoalescer({
publish: vi.fn(),
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coalescer.enqueue(publication(1, 10, settled))
coalescer.dispose()
coalescer.dispose()
expect(settled).toHaveBeenCalledOnce()
})
it('owns an in-flight callback until dispose and ignores its late transport callback', () => {
let transportSettle!: (result: { ok: true } | { ok: false; error: Error }) => void
const settled = vi.fn()
const coalescer = new SshPtySourceAckCoalescer({
publish: (_providerGeneration, _batch, settle) => {
transportSettle = settle
},
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coalescer.enqueue(publication(1, 10, settled))
coalescer.flush()
coalescer.dispose('generation closed')
transportSettle({ ok: true })
expect(settled).toHaveBeenCalledOnce()
expect(settled).toHaveBeenCalledWith({
ok: false,
error: expect.objectContaining({ message: 'generation closed' })
})
})
})
@@ -0,0 +1,230 @@
import {
MAX_PTY_ACK_ENTRIES,
type PtySourceCreditAckBatch
} from '../../shared/pty-source-credit-contract'
import type { SshPtySourceAckPublication } from './ssh-pty-source-obligation-contract'
export const SSH_PTY_ACK_FLUSH_MS = 8
export const SSH_PTY_ACK_EAGER_ADVANCE_SU = 64 * 1024
const ACK_PUBLICATION_WATERMARK_LIMIT = 1024
type AckSettlement = { ok: true } | { ok: false; error: Error }
type CoalescedEntry = {
publication: SshPtySourceAckPublication
members: SshPtySourceAckPublication[]
}
type InFlightBatch = {
entries: CoalescedEntry[]
settled: boolean
}
export type SshPtySourceAckCoalescerOptions = {
publish: (
providerGeneration: number,
batch: PtySourceCreditAckBatch,
onSettled: (result: AckSettlement) => void
) => void
schedule?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
cancelSchedule?: (timer: ReturnType<typeof setTimeout>) => void
onTokenClosed?: (identity: SshPtySourceAckPublication['identity']) => void
}
function ackKey(publication: SshPtySourceAckPublication): string {
const { identity } = publication
return `${identity.providerGeneration}\0${identity.clientGeneration}\0${identity.ownerGeneration}\0${identity.id}\0${identity.ptyIncarnation}\0${identity.deliveryToken}`
}
export class SshPtySourceAckCoalescer {
private readonly pending = new Map<string, CoalescedEntry>()
private readonly lastPublishedEndByToken = new Map<string, number>()
private readonly schedule: NonNullable<SshPtySourceAckCoalescerOptions['schedule']>
private readonly cancelSchedule: NonNullable<SshPtySourceAckCoalescerOptions['cancelSchedule']>
private timer: ReturnType<typeof setTimeout> | null = null
private timerDelayMs: number | null = null
private inFlight: InFlightBatch | null = null
private disposed = false
constructor(private readonly options: SshPtySourceAckCoalescerOptions) {
this.schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs))
this.cancelSchedule = options.cancelSchedule ?? clearTimeout
}
enqueue(publication: SshPtySourceAckPublication): void {
if (this.disposed) {
publication.onSettled({ ok: false, error: new Error('SSH PTY ACK coalescer disposed') })
return
}
const key = ackKey(publication)
const current = this.pending.get(key)
if (!current) {
this.pending.set(key, { publication, members: [publication] })
} else {
current.members.push(publication)
if (publication.ack.creditedEndSu > current.publication.ack.creditedEndSu) {
current.publication = publication
}
}
const lastPublished = this.lastPublishedEndByToken.get(key) ?? 0
const eager = publication.ack.creditedEndSu - lastPublished >= SSH_PTY_ACK_EAGER_ADVANCE_SU
this.requestFlush(eager ? 0 : SSH_PTY_ACK_FLUSH_MS)
}
flush(): void {
if (this.disposed || this.inFlight || this.pending.size === 0) {
return
}
if (this.timer) {
this.cancelSchedule(this.timer)
this.timer = null
this.timerDelayMs = null
}
const providerGeneration = this.pending.values().next().value!.publication
.identity.providerGeneration
const selected = Array.from(this.pending.entries())
.filter(([, entry]) => entry.publication.identity.providerGeneration === providerGeneration)
.slice(0, MAX_PTY_ACK_ENTRIES)
for (const [key] of selected) {
this.pending.delete(key)
}
const batch: InFlightBatch = {
entries: selected.map(([, entry]) => entry),
settled: false
}
this.inFlight = batch
const settle = (result: AckSettlement): void => this.settleBatch(batch, result)
try {
this.options.publish(
providerGeneration,
Object.freeze({
acknowledgements: Object.freeze(batch.entries.map((entry) => entry.publication.ack))
}),
settle
)
} catch (error) {
settle({
ok: false,
error: error instanceof Error ? error : new Error(String(error))
})
}
}
dispose(reason = 'SSH PTY ACK coalescer disposed'): void {
if (this.disposed) {
return
}
this.disposed = true
if (this.timer) {
this.cancelSchedule(this.timer)
this.timer = null
this.timerDelayMs = null
}
const result = { ok: false as const, error: new Error(reason) }
for (const entry of this.pending.values()) {
this.settleMembers(entry, result)
}
this.pending.clear()
if (this.inFlight && !this.inFlight.settled) {
const batch = this.inFlight
batch.settled = true
this.inFlight = null
for (const entry of batch.entries) {
this.settleMembers(entry, result)
}
}
}
cancelGeneration(providerGeneration: number, reason: string): void {
const result = { ok: false as const, error: new Error(reason) }
for (const [key, entry] of this.pending) {
if (entry.publication.identity.providerGeneration === providerGeneration) {
this.pending.delete(key)
this.settleMembers(entry, result)
}
}
const batch = this.inFlight
if (batch && !batch.settled) {
const retained: CoalescedEntry[] = []
for (const entry of batch.entries) {
if (entry.publication.identity.providerGeneration === providerGeneration) {
this.settleMembers(entry, result)
} else {
retained.push(entry)
}
}
batch.entries = retained
if (retained.length === 0) {
batch.settled = true
this.inFlight = null
}
}
const prefix = `${providerGeneration}\0`
for (const key of this.lastPublishedEndByToken.keys()) {
if (key.startsWith(prefix)) {
this.lastPublishedEndByToken.delete(key)
}
}
if (!this.inFlight && this.pending.size === 0 && this.timer) {
this.cancelSchedule(this.timer)
this.timer = null
this.timerDelayMs = null
} else if (!this.inFlight && this.pending.size > 0) {
this.requestFlush(0)
}
}
get pendingCount(): number {
return this.pending.size
}
private settleBatch(batch: InFlightBatch, result: AckSettlement): void {
if (batch.settled) {
return
}
batch.settled = true
if (this.inFlight === batch) {
this.inFlight = null
}
for (const entry of batch.entries) {
if (result.ok) {
const key = ackKey(entry.publication)
this.lastPublishedEndByToken.delete(key)
this.lastPublishedEndByToken.set(key, entry.publication.ack.creditedEndSu)
while (this.lastPublishedEndByToken.size > ACK_PUBLICATION_WATERMARK_LIMIT) {
this.lastPublishedEndByToken.delete(this.lastPublishedEndByToken.keys().next().value!)
}
}
this.settleMembers(entry, result)
}
if (this.pending.size > 0) {
this.requestFlush(0)
}
}
private settleMembers(entry: CoalescedEntry, result: AckSettlement): void {
for (const publication of entry.members.splice(0)) {
publication.onSettled(result)
}
}
private requestFlush(delayMs: number): void {
if (this.inFlight || this.disposed) {
return
}
if (this.timer) {
if (this.timerDelayMs !== null && this.timerDelayMs <= delayMs) {
return
}
this.cancelSchedule(this.timer)
this.timer = null
}
this.timerDelayMs = delayMs
this.timer = this.schedule(() => {
this.timer = null
this.timerDelayMs = null
this.flush()
}, delayMs)
this.timer.unref?.()
}
}
@@ -0,0 +1,33 @@
import type { SshPtySourceAckPublication } from './ssh-pty-source-obligation-contract'
import { reclaimPublishedSourcePrefix, type TokenRecord } from './ssh-pty-source-obligation-state'
export function createSshPtySourceAckPublication(
token: TokenRecord,
endSu: number,
spanOwners: Map<string, TokenRecord>,
onPublished: () => void
): SshPtySourceAckPublication {
let settled = false
return Object.freeze({
identity: token.identity,
ack: Object.freeze({
id: token.identity.id,
clientGeneration: token.identity.clientGeneration,
ownerGeneration: token.identity.ownerGeneration,
deliveryToken: token.identity.deliveryToken,
creditedEndSu: endSu
}),
onSettled: (result) => {
if (settled) {
return
}
settled = true
if (!result.ok || token.state === 'closed' || endSu > token.ackQueuedEndSu) {
return
}
token.ackPublishedEndSu = Math.max(token.ackPublishedEndSu, endSu)
reclaimPublishedSourcePrefix(token, spanOwners)
onPublished()
}
})
}
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PtySourceCreditAckBatch } from '../../shared/pty-source-credit-contract'
import { SshPtyOutputIntake } from './ssh-pty-output-intake'
import {
installSshPtySourceAckPublisher,
publishSshPtySourceAck
} from './ssh-pty-output-intake-registry'
afterEach(() => vi.useRealTimers())
describe('SSH PTY intake to relay ACK contract', () => {
it('publishes one cumulative relay-ID ACK only after model and desktop settlement', async () => {
vi.useFakeTimers()
const batches: PtySourceCreditAckBatch[] = []
const cleanup = installSshPtySourceAckPublisher(7, (batch, onSettled) => {
batches.push(batch)
onSettled({ ok: true })
})
let sequence = 0
const intake = new SshPtyOutputIntake({
getModelSequence: () => sequence,
acceptModel: (event) => {
sequence += event.rawLength
return { sequence, completion: Promise.resolve() }
},
project: () => {},
prepareExit: () => {},
finalizeExit: () => {},
publishSourceAck: publishSshPtySourceAck
})
try {
const receipt = await intake.acceptData({
id: 'ssh:target@@relay-pty-1',
data: 'data',
providerGeneration: 7,
ptyIncarnation: 'incarnation-1',
rawLength: 4,
transformed: false,
source: {
relayPtyId: 'relay-pty-1',
spanId: 'token-1:0:4',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 4
}
})
await vi.advanceTimersByTimeAsync(8)
expect(batches).toHaveLength(0)
const projectionId = receipt.projection.identity.projectionSemanticsId
intake.publishProjectionPrefix([projectionId], 4, 4)
intake.settleProjectionPrefix('ssh:target@@relay-pty-1', 4)
await vi.advanceTimersByTimeAsync(8)
expect(batches).toEqual([
{
acknowledgements: [
{
id: 'relay-pty-1',
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'token-1',
creditedEndSu: 4
}
]
}
])
await intake.acceptExit({
id: 'ssh:target@@relay-pty-1',
code: 0,
providerGeneration: 7,
ptyIncarnation: 'incarnation-1'
})
expect(intake.getDebugSnapshot().source).toEqual({
openedTokens: 0,
ptyIdentities: 0
})
} finally {
intake.dispose()
cleanup()
}
})
})
@@ -0,0 +1,38 @@
import type {
PtySourceCreditAck,
PtySourceDeliveryIdentity,
PtySourceSpan
} from '../../shared/pty-source-credit-contract'
export type SshPtySourceConsumerId = 'model' | 'desktop' | `remote:${string}`
export type SshPtySourceObligationState =
| Readonly<{ state: 'open' }>
| Readonly<{ state: 'transferring'; to: SshPtySourceConsumerId; reason: string }>
| Readonly<{ state: 'settled'; reason: string }>
| Readonly<{ state: 'transferred'; to: SshPtySourceConsumerId; reason: string }>
| Readonly<{ state: 'canceled'; reason: string }>
export type SshPtySourceAdmissionReservation = Readonly<{
reservationId: string
span: PtySourceSpan
requiredConsumers: readonly SshPtySourceConsumerId[]
}>
export type SshPtySourceTokenSnapshot = PtySourceDeliveryIdentity &
Readonly<{
state: 'active' | 'sealed-unsettled' | 'canceling' | 'closed'
receivedEndSu: number
obligationsTerminalEndSu: number
ackQueuedEndSu: number
ackPublishedEndSu: number
openSpans: number
exitPublished: boolean
generationClosed: boolean
}>
export type SshPtySourceAckPublication = Readonly<{
identity: PtySourceDeliveryIdentity
ack: PtySourceCreditAck
onSettled: (result: { ok: true } | { ok: false; error: Error }) => void
}>
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest'
import type {
PtySourceCreditAckBatch,
PtySourceDeliveryIdentity,
PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator'
const identity: PtySourceDeliveryIdentity = {
id: 'pty-1',
providerGeneration: 1,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken: 'token-1'
}
const span: PtySourceSpan = {
...identity,
spanId: 'span-1',
sourceStartSu: 0,
sourceEndSu: 4,
displayStart: 0,
displayEnd: 4,
data: 'data',
splittable: true,
transform: { transformed: false, rawLengthSu: 4, scalarSafe: true }
}
describe('SshPtySourceObligationCoordinator', () => {
it('is the single boundary from exact consumer settlement to upstream ACK publication', () => {
let written:
| {
batch: PtySourceCreditAckBatch
settle: (result: { ok: true } | { ok: false; error: Error }) => void
}
| undefined
const coordinator = new SshPtySourceObligationCoordinator({
publish: (_providerGeneration, batch, settle) => {
written = { batch, settle }
},
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coordinator.open(identity)
const reservation = coordinator.reserve(identity, span, ['model', 'desktop'])
coordinator.commit(reservation)
coordinator.settle({
identity,
spanId: span.spanId,
consumer: 'model',
reason: 'emulator-receipt'
})
coordinator.settle({
identity,
spanId: span.spanId,
consumer: 'desktop',
reason: 'renderer-parse'
})
coordinator.flushAcknowledgements()
expect(written?.batch.acknowledgements).toEqual([
expect.objectContaining({ deliveryToken: 'token-1', creditedEndSu: 4 })
])
expect(coordinator.snapshot(identity).ackPublishedEndSu).toBe(0)
written?.settle({ ok: true })
expect(coordinator.snapshot(identity).ackPublishedEndSu).toBe(4)
})
it('rejects an adapter transition carrying a stale full delivery identity', () => {
const coordinator = new SshPtySourceObligationCoordinator({
publish: vi.fn(),
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coordinator.open(identity)
coordinator.commit(coordinator.reserve(identity, span, ['model']))
expect(() =>
coordinator.settle({
identity: { ...identity, ownerGeneration: 99 },
spanId: span.spanId,
consumer: 'model',
reason: 'stale'
})
).toThrow('stale')
})
it('keeps later generations publishable after closing one generation', () => {
const publish = vi.fn()
const coordinator = new SshPtySourceObligationCoordinator({
publish,
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coordinator.open(identity)
coordinator.closeGeneration(1, 'replaced')
const nextIdentity = { ...identity, providerGeneration: 2, deliveryToken: 'token-2' }
const nextSpan = { ...span, ...nextIdentity, spanId: 'span-2' }
coordinator.open(nextIdentity)
coordinator.commit(coordinator.reserve(nextIdentity, nextSpan, ['model']))
coordinator.settle({
identity: nextIdentity,
spanId: nextSpan.spanId,
consumer: 'model',
reason: 'accepted'
})
coordinator.flushAcknowledgements()
expect(publish).toHaveBeenCalledWith(
2,
{
acknowledgements: [expect.objectContaining({ deliveryToken: 'token-2', creditedEndSu: 4 })]
},
expect.any(Function)
)
})
it('generation-closes every retained token on coordinator disposal', () => {
const coordinator = new SshPtySourceObligationCoordinator({
publish: vi.fn(),
schedule: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
cancelSchedule: vi.fn()
})
coordinator.open(identity)
coordinator.commit(coordinator.reserve(identity, span, ['model']))
coordinator.dispose('provider disposed')
expect(coordinator.snapshot(identity)).toMatchObject({
state: 'closed',
generationClosed: true,
openSpans: 0
})
expect(() => coordinator.open({ ...identity, deliveryToken: 'late-token' })).toThrow('disposed')
})
})
@@ -0,0 +1,267 @@
import {
ptySourceDeliveryKey,
samePtySourceDelivery,
type PtySourceDeliveryIdentity,
type PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import {
SshPtySourceAckCoalescer,
type SshPtySourceAckCoalescerOptions
} from './ssh-pty-source-ack-coalescer'
import {
SshPtySourceObligationLedger,
type SshPtySourceAdmissionReservation,
type SshPtySourceConsumerId,
type SshPtySourceObligationState,
type SshPtySourceTokenSnapshot
} from './ssh-pty-source-obligation-ledger'
export type SshPtySourceObligationTransition = Readonly<{
identity: PtySourceDeliveryIdentity
spanId: string
consumer: SshPtySourceConsumerId
reason: string
}>
type TerminalWaiter = {
resolve: () => void
reject: (error: Error) => void
}
type TerminalWaiterGroup = {
identity: PtySourceDeliveryIdentity
waiters: Set<TerminalWaiter>
}
export class SshPtySourceObligationCoordinator {
private readonly ledger: SshPtySourceObligationLedger
private readonly acknowledgements: SshPtySourceAckCoalescer
private readonly terminalWaiters = new Map<string, TerminalWaiterGroup>()
private disposed = false
constructor(options: SshPtySourceAckCoalescerOptions) {
this.ledger = new SshPtySourceObligationLedger(options.onTokenClosed)
this.acknowledgements = new SshPtySourceAckCoalescer(options)
}
open(identity: PtySourceDeliveryIdentity, checkpointSourceEndSu = 0): void {
if (this.disposed) {
throw new Error('SSH PTY source obligation coordinator is disposed')
}
this.ledger.open(identity, checkpointSourceEndSu)
}
reserve(
identity: PtySourceDeliveryIdentity,
span: PtySourceSpan,
requiredConsumers: readonly SshPtySourceConsumerId[]
): SshPtySourceAdmissionReservation {
return this.ledger.reserve(identity, span, requiredConsumers)
}
commit(reservation: SshPtySourceAdmissionReservation): void {
this.ledger.commit(reservation)
}
rollback(reservation: SshPtySourceAdmissionReservation): boolean {
return this.ledger.rollback(reservation)
}
rollbackCommitted(reservation: SshPtySourceAdmissionReservation): boolean {
const rolledBack = this.ledger.rollbackCommitted(reservation)
if (rolledBack) {
this.maybeResolveTerminal(reservation.span)
}
return rolledBack
}
settle(transition: SshPtySourceObligationTransition): boolean {
this.requireSpanIdentity(transition)
const changed = this.ledger.settle(transition.spanId, transition.consumer, transition.reason)
this.queueEligibleAck(transition.identity)
return changed
}
beginTransfer(transition: SshPtySourceObligationTransition, to: SshPtySourceConsumerId): boolean {
this.requireSpanIdentity(transition)
return this.ledger.beginTransfer(transition.spanId, transition.consumer, to, transition.reason)
}
commitTransfer(transition: Omit<SshPtySourceObligationTransition, 'reason'>): boolean {
this.requireSpanIdentity(transition)
const changed = this.ledger.commitTransfer(transition.spanId, transition.consumer)
this.queueEligibleAck(transition.identity)
return changed
}
cancelTransfer(transition: SshPtySourceObligationTransition): boolean {
this.requireSpanIdentity(transition)
const changed = this.ledger.cancelTransfer(
transition.spanId,
transition.consumer,
transition.reason
)
this.queueEligibleAck(transition.identity)
return changed
}
rollbackTransfer(transition: SshPtySourceObligationTransition): boolean {
this.requireSpanIdentity(transition)
return this.ledger.rollbackTransfer(transition.spanId, transition.consumer)
}
seal(identity: PtySourceDeliveryIdentity): void {
if (this.ledger.snapshot(identity).state === 'sealed-unsettled') {
return
}
this.ledger.seal(identity)
}
markExitPublished(identity: PtySourceDeliveryIdentity): void {
this.queueEligibleAck(identity)
this.ledger.markExitPublished(identity)
}
whenTerminal(identity: PtySourceDeliveryIdentity): Promise<void> {
if (this.isTerminal(identity)) {
return Promise.resolve()
}
const key = ptySourceDeliveryKey(identity)
let group = this.terminalWaiters.get(key)
if (!group) {
group = { identity: Object.freeze({ ...identity }), waiters: new Set() }
this.terminalWaiters.set(key, group)
}
return new Promise((resolve, reject) => {
group!.waiters.add({ resolve, reject })
})
}
beginExitTimeout(identity: PtySourceDeliveryIdentity) {
return this.ledger.beginExitTimeout(identity)
}
applyCancellationProof(
identity: PtySourceDeliveryIdentity,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): void {
const snapshot = this.ledger.snapshot(identity)
if (
snapshot.state === 'closed' &&
snapshot.receivedEndSu === proof.sentEndSu &&
snapshot.ackPublishedEndSu === proof.creditedEndSu
) {
return
}
this.ledger.applyCancellationProof(identity, proof)
this.rejectWaiters(
(group) => samePtySourceDelivery(group.identity, identity),
new Error('ssh_source_delivery_canceled')
)
}
applyRecoveryCancellationProof(
identity: PtySourceDeliveryIdentity,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): void {
this.ledger.applyRecoveryCancellationProof(identity, proof)
this.rejectWaiters(
(group) => samePtySourceDelivery(group.identity, identity),
new Error('ssh_source_delivery_canceled')
)
}
closeGeneration(providerGeneration: number, reason: string): number {
this.rejectWaiters(
(group) => group.identity.providerGeneration === providerGeneration,
new Error(reason)
)
const closed = this.ledger.closeGeneration(providerGeneration, reason)
this.acknowledgements.cancelGeneration(providerGeneration, reason)
return closed
}
snapshot(identity: PtySourceDeliveryIdentity): SshPtySourceTokenSnapshot {
return this.ledger.snapshot(identity)
}
modelAcceptedEnd(identity: PtySourceDeliveryIdentity): number {
return this.ledger.modelAcceptedEnd(identity)
}
obligation(spanId: string, consumer: SshPtySourceConsumerId): SshPtySourceObligationState {
return this.ledger.obligation(spanId, consumer)
}
spanIdentity(spanId: string): PtySourceSpan {
return this.ledger.spanIdentity(spanId)
}
hasRetainedSpan(spanId: string): boolean {
return this.ledger.hasRetainedSpan(spanId)
}
flushAcknowledgements(): void {
this.acknowledgements.flush()
}
dispose(reason?: string): void {
if (this.disposed) {
return
}
this.disposed = true
this.rejectWaiters(() => true, new Error(reason ?? 'SSH PTY source obligations disposed'))
this.ledger.closeAll(reason ?? 'SSH PTY source obligation coordinator disposed')
this.acknowledgements.dispose(reason)
}
private queueEligibleAck(identity: PtySourceDeliveryIdentity): void {
const publication = this.ledger.queueAck(identity)
if (publication) {
this.acknowledgements.enqueue(publication)
}
this.maybeResolveTerminal(identity)
}
private isTerminal(identity: PtySourceDeliveryIdentity): boolean {
const snapshot = this.ledger.snapshot(identity)
return (
snapshot.obligationsTerminalEndSu === snapshot.receivedEndSu &&
snapshot.ackQueuedEndSu === snapshot.receivedEndSu
)
}
private maybeResolveTerminal(identity: PtySourceDeliveryIdentity): void {
if (!this.isTerminal(identity)) {
return
}
const group = this.terminalWaiters.get(ptySourceDeliveryKey(identity))
if (!group) {
return
}
this.terminalWaiters.delete(ptySourceDeliveryKey(identity))
for (const waiter of group.waiters) {
waiter.resolve()
}
}
private rejectWaiters(predicate: (group: TerminalWaiterGroup) => boolean, error: Error): void {
for (const [key, group] of this.terminalWaiters) {
if (!predicate(group)) {
continue
}
this.terminalWaiters.delete(key)
for (const waiter of group.waiters) {
waiter.reject(error)
}
}
}
private requireSpanIdentity(
transition: Pick<SshPtySourceObligationTransition, 'identity' | 'spanId'>
): void {
if (!samePtySourceDelivery(this.ledger.spanIdentity(transition.spanId), transition.identity)) {
throw new Error('SSH PTY source obligation transition has a stale delivery identity')
}
}
}
@@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest'
import type {
PtySourceDeliveryIdentity,
PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import { SshPtySourceObligationLedger } from './ssh-pty-source-obligation-ledger'
function identity(
deliveryToken = 'token-1',
overrides: Partial<PtySourceDeliveryIdentity> = {}
): PtySourceDeliveryIdentity {
return {
id: 'pty-1',
providerGeneration: 1,
clientGeneration: 2,
ownerGeneration: 3,
ptyIncarnation: 'incarnation-1',
deliveryToken,
...overrides
}
}
function span(
owner: PtySourceDeliveryIdentity,
spanId: string,
sourceStartSu: number,
data: string
): PtySourceSpan {
return Object.freeze({
...owner,
spanId,
sourceStartSu,
sourceEndSu: sourceStartSu + data.length,
displayStart: sourceStartSu,
displayEnd: sourceStartSu + data.length,
data,
splittable: true,
transform: Object.freeze({
transformed: false,
rawLengthSu: data.length,
scalarSafe: true
})
})
}
function commitSpan(
ledger: SshPtySourceObligationLedger,
owner: PtySourceDeliveryIdentity,
sourceSpan: PtySourceSpan,
consumers: ('model' | 'desktop')[] = ['model', 'desktop']
) {
const reservation = ledger.reserve(owner, sourceSpan, consumers)
ledger.commit(reservation)
return reservation
}
describe('SshPtySourceObligationLedger', () => {
it('rolls back an uncommitted admission without consuming its source coordinate', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
const first = ledger.reserve(owner, span(owner, 'span-1', 0, 'abc'), ['model'])
expect(ledger.rollback(first)).toBe(true)
const retry = ledger.reserve(owner, span(owner, 'span-2', 0, 'abc'), ['model'])
ledger.commit(retry)
expect(ledger.snapshot(owner).receivedEndSu).toBe(3)
})
it('rolls back a committed tail while every obligation is still open', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
const reservation = commitSpan(ledger, owner, span(owner, 'span-1', 0, 'abc'))
expect(ledger.rollbackCommitted(reservation)).toBe(true)
expect(ledger.snapshot(owner)).toMatchObject({ receivedEndSu: 0, openSpans: 0 })
expect(() => ledger.spanIdentity('span-1')).toThrow('Unknown or reclaimed')
expect(
ledger.commit(ledger.reserve(owner, span(owner, 'span-2', 0, 'abc'), ['model']))
).toBeUndefined()
})
it('keeps terminal, queued, and successfully published ACK ends independent', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'span-1', 0, 'abcd'))
ledger.settle('span-1', 'model', 'emulator-receipt')
expect(ledger.snapshot(owner)).toMatchObject({
obligationsTerminalEndSu: 0,
ackQueuedEndSu: 0,
ackPublishedEndSu: 0
})
ledger.settle('span-1', 'desktop', 'renderer-parse')
expect(ledger.snapshot(owner)).toMatchObject({
obligationsTerminalEndSu: 4,
ackQueuedEndSu: 0,
ackPublishedEndSu: 0
})
const publication = ledger.queueAck(owner)!
expect(ledger.snapshot(owner)).toMatchObject({
obligationsTerminalEndSu: 4,
ackQueuedEndSu: 4,
ackPublishedEndSu: 0
})
publication.onSettled({ ok: false, error: new Error('write callback failed') })
expect(ledger.snapshot(owner)).toMatchObject({ ackQueuedEndSu: 4, ackPublishedEndSu: 0 })
ledger.retryQueuedAck(owner)!.onSettled({ ok: true })
expect(ledger.snapshot(owner)).toMatchObject({ ackPublishedEndSu: 4, openSpans: 0 })
})
it('checkpoints only the contiguous model-settled prefix', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'span-1', 0, 'abcd'))
commitSpan(ledger, owner, span(owner, 'span-2', 4, 'efgh'))
expect(ledger.modelAcceptedEnd(owner)).toBe(0)
ledger.settle('span-2', 'model', 'out-of-order')
expect(ledger.modelAcceptedEnd(owner)).toBe(0)
ledger.settle('span-1', 'model', 'emulator-receipt')
expect(ledger.modelAcceptedEnd(owner)).toBe(8)
})
it('continues checkpoints from the reclaimed ACK-published prefix', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'span-1', 0, 'abcd'))
commitSpan(ledger, owner, span(owner, 'span-2', 4, 'efgh'))
ledger.settle('span-1', 'model', 'emulator-receipt')
ledger.settle('span-1', 'desktop', 'renderer-parse')
ledger.queueAck(owner)!.onSettled({ ok: true })
expect(ledger.snapshot(owner)).toMatchObject({ ackPublishedEndSu: 4, openSpans: 1 })
expect(ledger.modelAcceptedEnd(owner)).toBe(4)
ledger.settle('span-2', 'model', 'emulator-receipt')
expect(ledger.modelAcceptedEnd(owner)).toBe(8)
})
it('requires an exact transfer fence before a desktop obligation becomes terminal', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'span-1', 0, 'abc'))
ledger.settle('span-1', 'model', 'emulator-receipt')
expect(ledger.beginTransfer('span-1', 'desktop', 'model', 'renderer-send-failed')).toBe(true)
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(0)
expect(ledger.commitTransfer('span-1', 'desktop')).toBe(true)
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(3)
expect(ledger.obligation('span-1', 'desktop')).toMatchObject({
state: 'transferred',
to: 'model'
})
})
it('keeps sealed exit state until final ACK publication succeeds', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'tail', 0, 'tail'), ['model'])
ledger.seal(owner)
expect(() => ledger.markExitPublished(owner)).toThrow('terminal ACK')
ledger.settle('tail', 'model', 'emulator-receipt')
const publication = ledger.queueAck(owner)!
ledger.markExitPublished(owner)
expect(ledger.snapshot(owner).state).toBe('sealed-unsettled')
publication.onSettled({ ok: true })
expect(ledger.snapshot(owner).state).toBe('closed')
})
it('publishes token cancellation intent before accepting timeout cleanup proof', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'tail', 0, 'tail'), ['model'])
ledger.seal(owner)
expect(() => ledger.applyCancellationProof(owner, { sentEndSu: 4, creditedEndSu: 0 })).toThrow()
expect(ledger.beginExitTimeout(owner)).toEqual({
id: owner.id,
deliveryToken: owner.deliveryToken,
clientGeneration: owner.clientGeneration,
ownerGeneration: owner.ownerGeneration
})
ledger.applyCancellationProof(owner, { sentEndSu: 4, creditedEndSu: 0 })
expect(ledger.snapshot(owner)).toMatchObject({ state: 'closed', openSpans: 0 })
})
it('ignores a late successful write callback after generation-close proof', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'span-1', 0, 'abc'), ['model'])
ledger.settle('span-1', 'model', 'accepted')
const publication = ledger.queueAck(owner)!
expect(ledger.closeGeneration(1, 'provider-closed')).toBe(1)
publication.onSettled({ ok: true })
expect(ledger.snapshot(owner)).toMatchObject({
state: 'closed',
generationClosed: true,
ackPublishedEndSu: 0
})
expect(() => ledger.obligation('span-1', 'model')).toThrow('reclaimed')
})
it('rejects stale generations, tokens, and non-contiguous source spans', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
expect(() =>
ledger.reserve(identity('stale'), span(identity('stale'), 'stale', 0, 'x'), ['model'])
).toThrow('stale')
expect(() => ledger.reserve(owner, span(owner, 'gap', 1, 'x'), ['model'])).toThrow(
'non-contiguous'
)
})
it('requires cancellation proof to match the exact local received and published ends', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'tail', 0, 'tail'), ['model'])
ledger.seal(owner)
ledger.beginExitTimeout(owner)
expect(() => ledger.applyCancellationProof(owner, { sentEndSu: 3, creditedEndSu: 0 })).toThrow(
'invalid'
)
expect(() => ledger.applyCancellationProof(owner, { sentEndSu: 4, creditedEndSu: 1 })).toThrow(
'invalid'
)
})
it('applies recovery cancellation proof over a locally admitted prefix', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner, 4)
commitSpan(ledger, owner, span(owner, 'recovery', 4, 'tail'), ['model'])
ledger.applyRecoveryCancellationProof(owner, { sentEndSu: 12, creditedEndSu: 4 })
expect(ledger.snapshot(owner)).toMatchObject({ state: 'closed', openSpans: 0 })
})
it('rejects recovery cancellation proof that misses local intake state', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner, 4)
commitSpan(ledger, owner, span(owner, 'recovery', 4, 'tail'), ['model'])
expect(() =>
ledger.applyRecoveryCancellationProof(owner, { sentEndSu: 7, creditedEndSu: 4 })
).toThrow('invalid')
expect(() =>
ledger.applyRecoveryCancellationProof(owner, { sentEndSu: 8, creditedEndSu: 5 })
).toThrow('invalid')
})
it('bounds closed-token tombstones and removes committed reservation indexes', () => {
const ledger = new SshPtySourceObligationLedger()
const owners = Array.from({ length: 300 }, (_, index) =>
identity(`token-${index}`, {
id: `pty-${index}`,
ptyIncarnation: `incarnation-${index}`
})
)
for (const [index, owner] of owners.entries()) {
ledger.open(owner)
commitSpan(ledger, owner, span(owner, `span-${index}`, 0, 'x'), ['model'])
ledger.closeGeneration(1, 'generation-closed')
}
expect(() => ledger.snapshot(owners[0])).toThrow('stale')
expect(ledger.snapshot(owners.at(-1)!)).toMatchObject({ state: 'closed' })
})
it('reclaims uncommitted reservations on exact cancellation proof', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
const reservation = ledger.reserve(owner, span(owner, 'pending', 0, 'x'), ['model'])
ledger.seal(owner)
ledger.beginExitTimeout(owner)
ledger.applyCancellationProof(owner, { sentEndSu: 0, creditedEndSu: 0 })
expect(ledger.rollback(reservation)).toBe(false)
expect(ledger.snapshot(owner)).toMatchObject({ state: 'closed', openSpans: 0 })
})
it('rejects reopening a recently closed one-use token', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
ledger.closeGeneration(owner.providerGeneration, 'closed')
expect(() => ledger.open(owner)).toThrow('already used')
})
})
@@ -0,0 +1,329 @@
import {
ptySourceDeliveryKey,
samePtySourceDelivery,
type PtySourceDeliveryIdentity,
type PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import {
assertNonNegativeSafeInteger,
assertPtySourceIdentity,
assertPtySourceSpan
} from '../../shared/pty-source-credit-validation'
import type {
SshPtySourceAckPublication,
SshPtySourceAdmissionReservation,
SshPtySourceConsumerId,
SshPtySourceObligationState,
SshPtySourceTokenSnapshot
} from './ssh-pty-source-obligation-contract'
import { createSshPtySourceAckPublication } from './ssh-pty-source-ack-publication'
import {
beginSourceExitTimeout,
cancelOpenSourceObligations,
closeAllSourceTokens,
closeSourceGeneration,
closeSourceToken,
createSourceSpanRecord,
createSourceToken,
markSourceExitPublished,
requireSourceSpan,
rollbackCommittedSourceSpan,
sealSourceToken,
snapshotSourceToken,
type ReservationRecord,
type TokenRecord
} from './ssh-pty-source-obligation-state'
import {
applySourceRecoveryCancellationProof,
cancelSourceObligationTransfer,
commitSourceObligationTransfer,
modelAcceptedSourceEnd,
rollbackSourceObligationTransfer,
transitionOpenSourceObligation
} from './ssh-pty-source-obligation-transitions'
export type {
SshPtySourceAckPublication,
SshPtySourceAdmissionReservation,
SshPtySourceConsumerId,
SshPtySourceObligationState,
SshPtySourceTokenSnapshot
} from './ssh-pty-source-obligation-contract'
export class SshPtySourceObligationLedger {
private readonly tokens = new Map<string, TokenRecord>()
private readonly closedSnapshots = new Map<string, SshPtySourceTokenSnapshot>()
private readonly reservations = new Map<string, ReservationRecord>()
private readonly spanOwners = new Map<string, TokenRecord>()
private nextReservationId = 1
constructor(
private readonly onTokenClosed: (identity: PtySourceDeliveryIdentity) => void = () => {}
) {}
open(identity: PtySourceDeliveryIdentity, checkpointSourceEndSu = 0): void {
assertPtySourceIdentity(identity)
assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu')
const key = ptySourceDeliveryKey(identity)
if (this.tokens.has(key) || this.closedSnapshots.has(key)) {
throw new Error('SSH PTY source token was already used')
}
this.tokens.set(key, createSourceToken(identity, checkpointSourceEndSu))
}
reserve(
identity: PtySourceDeliveryIdentity,
span: PtySourceSpan,
requiredConsumers: readonly SshPtySourceConsumerId[]
): SshPtySourceAdmissionReservation {
const token = this.requireToken(identity)
if (token.state !== 'active') {
throw new Error('SSH PTY source token no longer admits data')
}
assertPtySourceSpan(span)
if (
!samePtySourceDelivery(token.identity, span) ||
span.sourceStartSu !== token.receivedEndSu ||
this.spanOwners.has(span.spanId)
) {
throw new Error('SSH PTY source span is stale, duplicate, or non-contiguous')
}
const uniqueConsumers = Array.from(new Set(requiredConsumers))
if (!uniqueConsumers.includes('model')) {
throw new Error('SSH PTY source span requires the terminal model obligation')
}
const reservation = Object.freeze({
reservationId: `ssh-source-admission:${this.nextReservationId++}`,
span,
requiredConsumers: Object.freeze(uniqueConsumers)
})
this.reservations.set(reservation.reservationId, {
reservation,
state: 'reserved'
})
return reservation
}
commit(reservation: SshPtySourceAdmissionReservation): void {
const record = this.requireReservation(reservation)
if (record.state !== 'reserved') {
throw new Error('SSH PTY source admission reservation is not pending')
}
const token = this.requireToken(reservation.span)
if (token.state !== 'active' || token.receivedEndSu !== reservation.span.sourceStartSu) {
throw new Error('SSH PTY source admission reservation became stale')
}
const spanRecord = createSourceSpanRecord(reservation.span, reservation.requiredConsumers)
token.spans.push(spanRecord)
token.receivedEndSu = reservation.span.sourceEndSu
this.spanOwners.set(reservation.span.spanId, token)
this.reservations.delete(reservation.reservationId)
}
rollback(reservation: SshPtySourceAdmissionReservation): boolean {
const record = this.reservations.get(reservation.reservationId)
if (!record || record.reservation !== reservation || record.state !== 'reserved') {
return false
}
this.reservations.delete(reservation.reservationId)
return true
}
rollbackCommitted(reservation: SshPtySourceAdmissionReservation): boolean {
const token = this.tokens.get(ptySourceDeliveryKey(reservation.span))
if (!token || !samePtySourceDelivery(token.identity, reservation.span)) {
return false
}
return rollbackCommittedSourceSpan(token, reservation, this.spanOwners)
}
settle(spanId: string, consumer: SshPtySourceConsumerId, reason: string): boolean {
return this.transitionOpen(spanId, consumer, Object.freeze({ state: 'settled', reason }))
}
beginTransfer(
spanId: string,
consumer: SshPtySourceConsumerId,
to: SshPtySourceConsumerId,
reason: string
): boolean {
return this.transitionOpen(
spanId,
consumer,
Object.freeze({ state: 'transferring', to, reason })
)
}
commitTransfer(spanId: string, consumer: SshPtySourceConsumerId): boolean {
return commitSourceObligationTransfer(this.spanOwners, spanId, consumer)
}
cancelTransfer(spanId: string, consumer: SshPtySourceConsumerId, reason: string): boolean {
return cancelSourceObligationTransfer(this.spanOwners, spanId, consumer, reason)
}
rollbackTransfer(spanId: string, consumer: SshPtySourceConsumerId): boolean {
return rollbackSourceObligationTransfer(this.spanOwners, spanId, consumer)
}
queueAck(identity: PtySourceDeliveryIdentity): SshPtySourceAckPublication | null {
const token = this.requireToken(identity)
if (token.obligationsTerminalEndSu <= token.ackQueuedEndSu) {
return null
}
const endSu = token.obligationsTerminalEndSu
token.ackQueuedEndSu = endSu
return createSshPtySourceAckPublication(token, endSu, this.spanOwners, () =>
this.maybeClose(token)
)
}
retryQueuedAck(identity: PtySourceDeliveryIdentity): SshPtySourceAckPublication | null {
const token = this.requireToken(identity)
if (token.ackQueuedEndSu <= token.ackPublishedEndSu) {
return null
}
const endSu = token.ackQueuedEndSu
return createSshPtySourceAckPublication(token, endSu, this.spanOwners, () =>
this.maybeClose(token)
)
}
seal(identity: PtySourceDeliveryIdentity): void {
sealSourceToken(this.requireToken(identity))
}
markExitPublished(identity: PtySourceDeliveryIdentity): void {
const token = this.requireToken(identity)
markSourceExitPublished(token)
this.maybeClose(token)
}
beginExitTimeout(identity: PtySourceDeliveryIdentity): Readonly<{
id: string
deliveryToken: string
clientGeneration: number
ownerGeneration: number
}> {
return beginSourceExitTimeout(this.requireToken(identity))
}
applyCancellationProof(
identity: PtySourceDeliveryIdentity,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): void {
const token = this.requireToken(identity)
if (
token.state !== 'canceling' ||
proof.sentEndSu !== token.receivedEndSu ||
proof.creditedEndSu !== token.ackPublishedEndSu
) {
throw new Error('SSH PTY source cancellation proof is stale or invalid')
}
cancelOpenSourceObligations(token, 'relay-cancellation-proof')
this.closeToken(token)
}
applyRecoveryCancellationProof(
identity: PtySourceDeliveryIdentity,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): void {
const token = this.requireToken(identity)
applySourceRecoveryCancellationProof(token, proof)
this.closeToken(token)
}
closeGeneration(providerGeneration: number, reason: string): number {
return closeSourceGeneration(
this.tokens,
this.reservations,
providerGeneration,
reason,
(token) => this.closeToken(token)
)
}
closeAll(reason: string): number {
return closeAllSourceTokens(this.tokens, this.reservations, reason, (token) =>
this.closeToken(token)
)
}
snapshot(identity: PtySourceDeliveryIdentity): SshPtySourceTokenSnapshot {
const key = ptySourceDeliveryKey(identity)
const token = this.tokens.get(key)
if (token && samePtySourceDelivery(token.identity, identity)) {
return snapshotSourceToken(token)
}
const closed = this.closedSnapshots.get(key)
if (closed && samePtySourceDelivery(closed, identity)) {
return closed
}
throw new Error('Unknown or stale SSH PTY source token')
}
modelAcceptedEnd(identity: PtySourceDeliveryIdentity): number {
return modelAcceptedSourceEnd(this.requireToken(identity))
}
obligation(spanId: string, consumer: SshPtySourceConsumerId): SshPtySourceObligationState {
const obligation = requireSourceSpan(this.spanOwners, spanId).span.obligations.get(consumer)
if (!obligation) {
throw new Error('SSH PTY source consumer obligation does not exist')
}
return obligation
}
spanIdentity(spanId: string): PtySourceSpan {
return requireSourceSpan(this.spanOwners, spanId).span.span
}
hasRetainedSpan(spanId: string): boolean {
return this.spanOwners.has(spanId)
}
private transitionOpen(
spanId: string,
consumer: SshPtySourceConsumerId,
next: SshPtySourceObligationState
): boolean {
return transitionOpenSourceObligation(this.spanOwners, spanId, consumer, next)
}
private maybeClose(token: TokenRecord): void {
if (
token.state === 'sealed-unsettled' &&
token.exitPublished &&
token.ackPublishedEndSu === token.receivedEndSu
) {
this.closeToken(token)
}
}
private closeToken(token: TokenRecord): void {
closeSourceToken(
token,
this.tokens,
this.closedSnapshots,
this.reservations,
this.spanOwners,
this.onTokenClosed
)
}
private requireReservation(reservation: SshPtySourceAdmissionReservation): ReservationRecord {
const record = this.reservations.get(reservation.reservationId)
if (!record || record.reservation !== reservation) {
throw new Error('Unknown SSH PTY source admission reservation')
}
return record
}
private requireToken(identity: PtySourceDeliveryIdentity): TokenRecord {
const token = this.tokens.get(ptySourceDeliveryKey(identity))
if (!token || !samePtySourceDelivery(token.identity, identity)) {
throw new Error('Unknown or stale SSH PTY source token')
}
return token
}
}
@@ -0,0 +1,287 @@
import type {
PtySourceDeliveryIdentity,
PtySourceSpan
} from '../../shared/pty-source-credit-contract'
import {
ptySourceDeliveryKey,
samePtySourceDelivery
} from '../../shared/pty-source-credit-contract'
import type {
SshPtySourceAdmissionReservation,
SshPtySourceConsumerId,
SshPtySourceObligationState,
SshPtySourceTokenSnapshot
} from './ssh-pty-source-obligation-contract'
export const CLOSED_SOURCE_TOKEN_TOMBSTONE_LIMIT = 256
export type SpanRecord = {
span: PtySourceSpan
obligations: Map<SshPtySourceConsumerId, SshPtySourceObligationState>
}
export type ReservationRecord = {
reservation: SshPtySourceAdmissionReservation
state: 'reserved' | 'committed' | 'rolled-back'
}
export type TokenRecord = {
identity: PtySourceDeliveryIdentity
state: 'active' | 'sealed-unsettled' | 'canceling' | 'closed'
checkpointSourceEndSu: number
receivedEndSu: number
obligationsTerminalEndSu: number
ackQueuedEndSu: number
ackPublishedEndSu: number
spans: SpanRecord[]
exitPublished: boolean
generationClosed: boolean
}
export function createSourceToken(
identity: PtySourceDeliveryIdentity,
checkpointSourceEndSu: number
): TokenRecord {
return {
identity: Object.freeze({ ...identity }),
state: 'active',
checkpointSourceEndSu,
receivedEndSu: checkpointSourceEndSu,
obligationsTerminalEndSu: checkpointSourceEndSu,
ackQueuedEndSu: checkpointSourceEndSu,
ackPublishedEndSu: checkpointSourceEndSu,
spans: [],
exitPublished: false,
generationClosed: false
}
}
export function createSourceSpanRecord(
span: PtySourceSpan,
consumers: readonly SshPtySourceConsumerId[]
): SpanRecord {
return {
span,
obligations: new Map(
consumers.map((consumer) => [consumer, Object.freeze({ state: 'open' as const })])
)
}
}
export function sealSourceToken(token: TokenRecord): void {
if (token.state !== 'active') {
throw new Error('SSH PTY source token cannot be sealed from its current state')
}
token.state = 'sealed-unsettled'
}
export function markSourceExitPublished(token: TokenRecord): void {
if (token.state !== 'sealed-unsettled') {
throw new Error('SSH PTY source exit publication requires a sealed token')
}
if (
token.obligationsTerminalEndSu !== token.receivedEndSu ||
token.ackQueuedEndSu !== token.receivedEndSu
) {
throw new Error('SSH PTY source exit cannot publish before terminal ACK queueing')
}
token.exitPublished = true
}
export function beginSourceExitTimeout(token: TokenRecord): Readonly<{
id: string
deliveryToken: string
clientGeneration: number
ownerGeneration: number
}> {
if (token.state !== 'sealed-unsettled') {
throw new Error('SSH PTY source exit timeout requires a sealed token')
}
token.state = 'canceling'
return Object.freeze({
id: token.identity.id,
deliveryToken: token.identity.deliveryToken,
clientGeneration: token.identity.clientGeneration,
ownerGeneration: token.identity.ownerGeneration
})
}
function obligationIsTerminal(obligation: SshPtySourceObligationState): boolean {
return (
obligation.state === 'settled' ||
obligation.state === 'transferred' ||
obligation.state === 'canceled'
)
}
export function snapshotSourceToken(token: TokenRecord): SshPtySourceTokenSnapshot {
return Object.freeze({
...token.identity,
state: token.state,
receivedEndSu: token.receivedEndSu,
obligationsTerminalEndSu: token.obligationsTerminalEndSu,
ackQueuedEndSu: token.ackQueuedEndSu,
ackPublishedEndSu: token.ackPublishedEndSu,
openSpans: token.spans.length,
exitPublished: token.exitPublished,
generationClosed: token.generationClosed
})
}
export function advanceSourceTerminalEnd(token: TokenRecord): void {
let endSu = token.obligationsTerminalEndSu
for (const record of token.spans) {
if (record.span.sourceEndSu <= endSu) {
continue
}
if (
record.span.sourceStartSu !== endSu ||
!Array.from(record.obligations.values()).every(obligationIsTerminal)
) {
break
}
endSu = record.span.sourceEndSu
}
token.obligationsTerminalEndSu = endSu
}
export function cancelOpenSourceObligations(token: TokenRecord, reason: string): void {
for (const record of token.spans) {
for (const [consumer, obligation] of record.obligations) {
if (obligation.state === 'open' || obligation.state === 'transferring') {
record.obligations.set(consumer, Object.freeze({ state: 'canceled', reason }))
}
}
}
advanceSourceTerminalEnd(token)
}
export function reclaimPublishedSourcePrefix(
token: TokenRecord,
spanOwners: Map<string, TokenRecord>
): void {
while (token.spans[0]?.span.sourceEndSu <= token.ackPublishedEndSu) {
const record = token.spans.shift()!
spanOwners.delete(record.span.spanId)
}
}
export function releaseSourceTokenSpans(
token: TokenRecord,
spanOwners: Map<string, TokenRecord>
): void {
for (const record of token.spans) {
spanOwners.delete(record.span.spanId)
}
token.spans = []
}
export function releaseSourceTokenReservations(
token: TokenRecord,
reservations: Map<string, ReservationRecord>
): void {
for (const [reservationId, record] of reservations) {
if (samePtySourceDelivery(record.reservation.span, token.identity)) {
record.state = 'rolled-back'
reservations.delete(reservationId)
}
}
}
export function rollbackCommittedSourceSpan(
token: TokenRecord,
reservation: SshPtySourceAdmissionReservation,
spanOwners: Map<string, TokenRecord>
): boolean {
const last = token.spans.at(-1)
if (
last?.span !== reservation.span ||
token.receivedEndSu !== reservation.span.sourceEndSu ||
token.obligationsTerminalEndSu > reservation.span.sourceStartSu ||
token.ackQueuedEndSu > reservation.span.sourceStartSu ||
Array.from(last.obligations.values()).some((obligation) => obligation.state !== 'open')
) {
return false
}
token.spans.pop()
token.receivedEndSu = reservation.span.sourceStartSu
spanOwners.delete(reservation.span.spanId)
return true
}
export function requireSourceSpan(
spanOwners: ReadonlyMap<string, TokenRecord>,
spanId: string
): { token: TokenRecord; span: SpanRecord } {
const token = spanOwners.get(spanId)
const span = token?.spans.find((candidate) => candidate.span.spanId === spanId)
if (!token || !span) {
throw new Error('Unknown or reclaimed SSH PTY source span')
}
return { token, span }
}
export function closeSourceGeneration(
tokens: Map<string, TokenRecord>,
reservations: Map<string, ReservationRecord>,
providerGeneration: number,
reason: string,
closeToken: (token: TokenRecord) => void
): number {
let closed = 0
for (const token of Array.from(tokens.values())) {
if (token.identity.providerGeneration !== providerGeneration || token.state === 'closed') {
continue
}
token.generationClosed = true
cancelOpenSourceObligations(token, reason)
closeToken(token)
closed++
}
for (const [id, record] of reservations) {
if (
record.state === 'reserved' &&
record.reservation.span.providerGeneration === providerGeneration
) {
record.state = 'rolled-back'
reservations.delete(id)
}
}
return closed
}
export function closeAllSourceTokens(
tokens: Map<string, TokenRecord>,
reservations: Map<string, ReservationRecord>,
reason: string,
closeToken: (token: TokenRecord) => void
): number {
let closed = 0
const generations = new Set(
Array.from(tokens.values(), (token) => token.identity.providerGeneration)
)
for (const providerGeneration of generations) {
closed += closeSourceGeneration(tokens, reservations, providerGeneration, reason, closeToken)
}
return closed
}
export function closeSourceToken(
token: TokenRecord,
tokens: Map<string, TokenRecord>,
closedSnapshots: Map<string, SshPtySourceTokenSnapshot>,
reservations: Map<string, ReservationRecord>,
spanOwners: Map<string, TokenRecord>,
onTokenClosed: (identity: PtySourceDeliveryIdentity) => void
): void {
token.state = 'closed'
releaseSourceTokenSpans(token, spanOwners)
releaseSourceTokenReservations(token, reservations)
const key = ptySourceDeliveryKey(token.identity)
tokens.delete(key)
closedSnapshots.set(key, snapshotSourceToken(token))
while (closedSnapshots.size > CLOSED_SOURCE_TOKEN_TOMBSTONE_LIMIT) {
closedSnapshots.delete(closedSnapshots.keys().next().value!)
}
onTokenClosed(token.identity)
}
@@ -0,0 +1,100 @@
import type {
SshPtySourceConsumerId,
SshPtySourceObligationState
} from './ssh-pty-source-obligation-contract'
import {
advanceSourceTerminalEnd,
cancelOpenSourceObligations,
requireSourceSpan,
type TokenRecord
} from './ssh-pty-source-obligation-state'
export function applySourceRecoveryCancellationProof(
token: TokenRecord,
proof: Readonly<{ sentEndSu: number; creditedEndSu: number }>
): void {
if (
token.state !== 'active' ||
proof.sentEndSu < token.receivedEndSu ||
proof.creditedEndSu !== token.ackPublishedEndSu ||
proof.creditedEndSu > token.receivedEndSu
) {
throw new Error('SSH PTY source recovery cancellation proof is stale or invalid')
}
cancelOpenSourceObligations(token, 'relay-recovery-cancellation-proof')
}
export function transitionOpenSourceObligation(
spanOwners: ReadonlyMap<string, TokenRecord>,
spanId: string,
consumer: SshPtySourceConsumerId,
next: SshPtySourceObligationState
): boolean {
const { token, span } = requireSourceSpan(spanOwners, spanId)
if (span.obligations.get(consumer)?.state !== 'open') {
return false
}
span.obligations.set(consumer, next)
advanceSourceTerminalEnd(token)
return true
}
export function commitSourceObligationTransfer(
spanOwners: ReadonlyMap<string, TokenRecord>,
spanId: string,
consumer: SshPtySourceConsumerId
): boolean {
const { token, span } = requireSourceSpan(spanOwners, spanId)
const current = span.obligations.get(consumer)
if (current?.state !== 'transferring') {
return false
}
span.obligations.set(
consumer,
Object.freeze({ state: 'transferred', to: current.to, reason: current.reason })
)
advanceSourceTerminalEnd(token)
return true
}
export function cancelSourceObligationTransfer(
spanOwners: ReadonlyMap<string, TokenRecord>,
spanId: string,
consumer: SshPtySourceConsumerId,
reason: string
): boolean {
const { token, span } = requireSourceSpan(spanOwners, spanId)
if (span.obligations.get(consumer)?.state !== 'transferring') {
return false
}
span.obligations.set(consumer, Object.freeze({ state: 'canceled', reason }))
advanceSourceTerminalEnd(token)
return true
}
export function rollbackSourceObligationTransfer(
spanOwners: ReadonlyMap<string, TokenRecord>,
spanId: string,
consumer: SshPtySourceConsumerId
): boolean {
const { span } = requireSourceSpan(spanOwners, spanId)
if (span.obligations.get(consumer)?.state !== 'transferring') {
return false
}
span.obligations.set(consumer, Object.freeze({ state: 'open' }))
return true
}
export function modelAcceptedSourceEnd(token: TokenRecord): number {
let acceptedEndSu = token.ackPublishedEndSu
for (const record of token.spans) {
if (
record.span.sourceStartSu !== acceptedEndSu ||
record.obligations.get('model')?.state !== 'settled'
) {
break
}
acceptedEndSu = record.span.sourceEndSu
}
return acceptedEndSu
}
+132 -45
View File
@@ -11,6 +11,8 @@ const {
mockConnectionManager,
mockDeployAndLaunchRelay,
mockForceStopRelayForTarget,
mockAcceptSshPtyOutputData,
mockAcceptSshPtyOutputExit,
mockMux,
mockPtyProvider,
mockFsProvider,
@@ -49,10 +51,13 @@ const {
},
mockDeployAndLaunchRelay: vi.fn(),
mockForceStopRelayForTarget: vi.fn(),
mockAcceptSshPtyOutputData: vi.fn().mockResolvedValue({}),
mockAcceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined),
mockMux: {
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false),
onNotification: vi.fn(),
onNotificationByMethod: vi.fn().mockReturnValue(() => {}),
onRequest: vi.fn().mockReturnValue(() => {}),
onDispose: vi.fn().mockReturnValue(() => {}),
request: vi.fn().mockResolvedValue({}),
@@ -65,7 +70,8 @@ const {
onReplay: vi.fn(),
attach: vi.fn(),
attachForReconnect: vi.fn().mockResolvedValue({}),
shutdown: vi.fn()
shutdown: vi.fn(),
providerGeneration: 0
},
mockFsProvider: {},
mockGitProvider: {},
@@ -100,6 +106,25 @@ vi.mock('electron', () => ({
}
}))
vi.mock('./ssh-pty-output-intake-registry', () => ({
acceptSshPtyOutputData: mockAcceptSshPtyOutputData,
acceptSshPtyOutputExit: mockAcceptSshPtyOutputExit,
allocateSshPtyProviderGeneration: (() => {
let generation = 0
return () => ++generation
})(),
beginSshPtyOutputGenerationMigration: vi.fn(() => ({
byPty: new Map(),
completion: Promise.resolve()
})),
applySshPtySourceCancellationProof: vi.fn().mockReturnValue(false),
applySshPtySourceRecoveryCancellationProof: vi.fn().mockReturnValue(false),
closeSshPtyOutputGeneration: vi.fn(),
getSshPtyAcceptedSourceCheckpoints: vi.fn().mockReturnValue([]),
installSshPtySourceAckPublisher: vi.fn().mockReturnValue(() => {}),
installSshPtySourceCancellationPublisher: vi.fn().mockReturnValue(() => {})
}))
vi.mock('../ssh/ssh-connection-store', () => ({
SshConnectionStore: class MockSshConnectionStore {
constructor() {
@@ -142,7 +167,8 @@ vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: (err: unknown) =>
(err instanceof Error ? err.message : String(err)).includes('not found'),
SshPtyProvider: class MockSshPtyProvider {
constructor() {
constructor(_targetId: unknown, _mux: unknown, _env: unknown, providerGeneration: number) {
mockPtyProvider.providerGeneration = providerGeneration
return mockPtyProvider
}
}
@@ -222,6 +248,9 @@ import {
type SshConnectionState,
type SshTarget
} from '../../shared/ssh-types'
import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session'
import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract'
import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract'
import {
clearProviderPtyState,
deletePtyOwnership,
@@ -231,6 +260,16 @@ import {
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
describe('SSH IPC handlers', () => {
const relayBuildId = '0.1.0+ipc-test'
const ipcTestSource = {
relayPtyId: 'remote-pty',
spanId: 'ipc-test-delivery:0:5',
clientGeneration: 1,
ownerGeneration: 1,
deliveryToken: 'ipc-test-delivery',
sourceStartSu: 0,
sourceEndSu: 5
} as const
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mockStore = {
getRepos: () => [],
@@ -272,7 +311,8 @@ describe('SSH IPC handlers', () => {
const relayLostStabilizedMs = 5_000
const createRelayLaunchResult = () => ({
transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() },
platform: 'linux-x64'
platform: 'linux-x64',
serverBuildId: relayBuildId
})
const getLatestRelayDisposeCallback = (): RelayDisposeCallback => {
const calls = mockMux.onDispose.mock.calls
@@ -322,21 +362,43 @@ describe('SSH IPC handlers', () => {
mockConnectionManager.setCallbacks.mockReset()
mockConnectionManager.callbacksRef.current = null
mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined)
mockAcceptSshPtyOutputData.mockReset().mockResolvedValue({})
mockAcceptSshPtyOutputExit.mockReset().mockResolvedValue(undefined)
mockDeployAndLaunchRelay.mockReset().mockResolvedValue({
transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() },
platform: 'linux-x64'
platform: 'linux-x64',
serverBuildId: relayBuildId
})
mockMux.dispose.mockReset()
mockMux.isDisposed.mockReset().mockReturnValue(false)
mockMux.onNotification.mockReset()
mockMux.onNotificationByMethod.mockReset().mockReturnValue(() => {})
mockMux.onDispose.mockReset().mockReturnValue(() => {})
mockMux.request.mockReset().mockImplementation((method: string) =>
Promise.resolve(
method === 'pty.openClient'
? {
protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION,
serverBuildId: relayBuildId,
clientGeneration: 1,
role: 'session-owner',
ownerGeneration: 1,
ownerLease: 'ipc-test-owner',
capabilities: {
outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU }
}
}
: {}
)
)
mockMux.probeLiveness.mockReset().mockResolvedValue(false)
mockPtyProvider.onData.mockReset()
mockPtyProvider.onExit.mockReset()
mockPtyProvider.onReplay.mockReset()
mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({})
mockPtyProvider.shutdown.mockReset()
mockPtyProvider.providerGeneration = 0
mockRegisterSshGitProvider.mockReset()
mockPortForwardManager.addForward.mockReset()
mockPortForwardManager.updateForward.mockReset()
@@ -565,6 +627,7 @@ describe('SSH IPC handlers', () => {
}
mockDeployAndLaunchRelay.mockResolvedValueOnce({
transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() },
serverBuildId: relayBuildId,
hostPlatform
})
mockSshStore.getTarget.mockReturnValue(target)
@@ -1050,7 +1113,7 @@ describe('SSH IPC handlers', () => {
}
})
it('forwards remote PTY events into the runtime', async () => {
it('forwards remote PTY events through the output intake authority', async () => {
const runtime = {
onPtyData: vi.fn(),
onPtyExit: vi.fn()
@@ -1073,24 +1136,47 @@ describe('SSH IPC handlers', () => {
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as
| ((payload: { id: string; data: string }) => void)
| undefined
const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as SshPtyDataCallback | undefined
const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as
| ((payload: { id: string; code: number }) => void)
| ((payload: {
id: string
code: number
providerGeneration: number
ptyIncarnation: string
}) => void)
| undefined
onData?.({ id: 'remote-pty', data: 'hello' })
onExit?.({ id: 'remote-pty', code: 7 })
onData?.({
id: 'remote-pty',
data: 'hello',
providerGeneration: mockPtyProvider.providerGeneration,
ptyIncarnation: 'ipc-test-pty',
source: ipcTestSource
})
onExit?.({
id: 'remote-pty',
code: 7,
providerGeneration: mockPtyProvider.providerGeneration,
ptyIncarnation: 'ipc-test-pty'
})
expect(runtime.onPtyData).toHaveBeenCalledWith(
'remote-pty',
'hello',
expect.any(Number),
'hello'.length,
undefined
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7, undefined)
expect(mockAcceptSshPtyOutputData).toHaveBeenCalledWith({
id: 'remote-pty',
data: 'hello',
providerGeneration: mockPtyProvider.providerGeneration,
ptyIncarnation: 'ipc-test-pty',
rawLength: 'hello'.length,
transformed: false,
source: ipcTestSource
})
expect(mockAcceptSshPtyOutputExit).toHaveBeenCalledWith({
id: 'remote-pty',
code: 7,
providerGeneration: mockPtyProvider.providerGeneration,
ptyIncarnation: 'ipc-test-pty'
})
expect(runtime.onPtyData).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => {
@@ -1380,7 +1466,7 @@ describe('SSH IPC handlers', () => {
expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled()
})
it('refreshes live session callbacks to the newest window, store, and runtime', async () => {
it('refreshes live session callbacks to the newest window and output authorities', async () => {
const firstWindow = createMockWindow()
const secondWindow = createMockWindow()
const firstRuntime = {
@@ -1411,11 +1497,14 @@ describe('SSH IPC handlers', () => {
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as
| ((payload: { id: string; data: string }) => void)
| undefined
const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as SshPtyDataCallback | undefined
const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as
| ((payload: { id: string; code: number }) => void)
| ((payload: {
id: string
code: number
providerGeneration: number
ptyIncarnation: string
}) => void)
| undefined
const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as
| ((targetId: string, ports: unknown[], platform: string) => void)
@@ -1434,8 +1523,19 @@ describe('SSH IPC handlers', () => {
error: 'network down',
reconnectAttempt: 0
})
onData?.({ id: 'remote-pty', data: 'hello' })
onExit?.({ id: 'remote-pty', code: 9 })
onData?.({
id: 'remote-pty',
data: 'hello',
providerGeneration: mockPtyProvider.providerGeneration,
ptyIncarnation: 'ipc-test-pty',
source: ipcTestSource
})
onExit?.({
id: 'remote-pty',
code: 9,
providerGeneration: mockPtyProvider.providerGeneration,
ptyIncarnation: 'ipc-test-pty'
})
onDetectedPorts?.(
'ssh-1',
[{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }],
@@ -1454,33 +1554,20 @@ describe('SSH IPC handlers', () => {
connectionGeneration: 1
}
})
expect(secondWindow.webContents.send).toHaveBeenCalledWith(
'pty:data',
expect(mockAcceptSshPtyOutputData).toHaveBeenCalledWith(
expect.objectContaining({ id: 'remote-pty', data: 'hello' })
)
expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: 'remote-pty',
code: 9
})
expect(mockAcceptSshPtyOutputExit).toHaveBeenCalledWith(
expect.objectContaining({ id: 'remote-pty', code: 9 })
)
expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', {
targetId: 'ssh-1',
ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })])
})
expect(secondRuntime.onPtyData).toHaveBeenCalledWith(
'remote-pty',
'hello',
expect.any(Number),
'hello'.length,
undefined
)
expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9, undefined)
expect(secondRuntime.onPtyData).not.toHaveBeenCalled()
expect(secondRuntime.onPtyExit).not.toHaveBeenCalled()
expect(firstRuntime.onPtyData).not.toHaveBeenCalled()
expect(firstRuntime.onPtyExit).not.toHaveBeenCalled()
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
})
it('re-registers without replacing managers when no targets are connected', () => {
+66 -7
View File
@@ -106,7 +106,8 @@ describe('tcc prompt notice threshold', () => {
const [, contents] = writeFileAtomically.mock.calls.at(-1) as [string, string]
expect(JSON.parse(contents)).toMatchObject({
promptCount: TCC_PROMPT_NOTICE_THRESHOLD,
notified: true
notified: true,
acknowledgedAfterClose: true
})
})
@@ -142,7 +143,11 @@ describe('tcc prompt notice threshold', () => {
expect(consumePendingTccPromptNotice(2)).toBeNull()
const [, contents] = writeFileAtomically.mock.calls.at(-1) as [string, string]
expect(JSON.parse(contents)).toMatchObject({ dismissed: true, notified: true })
expect(JSON.parse(contents)).toMatchObject({
dismissed: true,
notified: true,
acknowledgedAfterClose: true
})
})
it('routes a later prompt to the replacement main window', () => {
@@ -297,11 +302,43 @@ describe('tcc prompt notice threshold', () => {
}
})
it('delivers a tally persisted below the old threshold without respawning the watcher', () => {
it.each([
{ promptCount: 2, notified: false },
{ promptCount: 1, notified: true }
])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false }))
try {
const mainWindow = createWindowStub()
initTccPromptNotice(mainWindow as never)
expect(watchStart).toHaveBeenCalledOnce()
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(consumePendingTccPromptNotice(1)).toBeNull()
watchOptions[0].onPrompt()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('macosTccPrompts:threshold', {
promptCount: 1
})
expect(watchStop).toHaveBeenCalledOnce()
expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 1 })
} finally {
Object.defineProperty(process, 'platform', platform!)
}
})
it('replays an unclosed notice from the new delivery contract', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({ promptCount: 2, notified: false, dismissed: false })
JSON.stringify({
promptCount: 1,
notified: false,
dismissed: false,
acknowledgedAfterClose: false
})
)
try {
const mainWindow = createWindowStub()
@@ -309,10 +346,32 @@ describe('tcc prompt notice threshold', () => {
expect(watchStart).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('macosTccPrompts:threshold', {
promptCount: 2
promptCount: 1
})
expect(mainWindow.once).not.toHaveBeenCalled()
expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 2 })
expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 1 })
} finally {
Object.defineProperty(process, 'platform', platform!)
}
})
it('does not replay a notice acknowledged after close', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
promptCount: 1,
notified: true,
dismissed: false,
acknowledgedAfterClose: true
})
)
try {
const mainWindow = createWindowStub()
initTccPromptNotice(mainWindow as never)
expect(watchStart).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(consumePendingTccPromptNotice(1)).toBeNull()
} finally {
Object.defineProperty(process, 'platform', platform!)
}
+18 -5
View File
@@ -31,9 +31,15 @@ type TccPromptTally = {
promptCount: number
notified: boolean
dismissed: boolean
acknowledgedAfterClose: boolean
}
const EMPTY_TALLY: TccPromptTally = { promptCount: 0, notified: false, dismissed: false }
const EMPTY_TALLY: TccPromptTally = {
promptCount: 0,
notified: false,
dismissed: false,
acknowledgedAfterClose: false
}
let tally: TccPromptTally = { ...EMPTY_TALLY }
let mainWindowRef: BrowserWindow | null = null
@@ -50,10 +56,17 @@ function tallyPath(): string {
function loadTally(): TccPromptTally {
try {
const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial<TccPromptTally>
const dismissed = parsed.dismissed === true
if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') {
// Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing.
return { ...EMPTY_TALLY }
}
const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true
return {
promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0,
notified: parsed.notified === true,
dismissed: parsed.dismissed === true
notified: parsed.notified === true && (dismissed || acknowledgedAfterClose),
dismissed,
acknowledgedAfterClose
}
} catch {
return { ...EMPTY_TALLY }
@@ -108,7 +121,7 @@ export function acknowledgePendingTccPromptNotice(ownerToken: number, claimId: n
return
}
pendingClaim = null
tally = { ...tally, notified: true }
tally = { ...tally, notified: true, acknowledgedAfterClose: true }
saveTally()
}
@@ -124,7 +137,7 @@ export function releasePendingTccPromptNotice(ownerToken: number, claimId?: numb
/** Permanently stops the notice for this user; the watcher shuts down with it. */
export function dismissTccPromptNotice(): void {
pendingClaim = null
tally = { ...tally, dismissed: true, notified: true }
tally = { ...tally, dismissed: true, notified: true, acknowledgedAfterClose: true }
saveTally()
stopTccPromptNotice()
}
+20 -1
View File
@@ -1548,6 +1548,26 @@ describe('Store', () => {
expect(updatedTarget).not.toHaveProperty('systemSshConnectionReuse')
})
it('drops retired per-target SSH terminal source-credit selections', async () => {
const store = await createStore()
store.addSshTarget({
id: 'ssh-source-credit-on',
label: 'Noisy build host',
host: 'build.example.com',
port: 22,
username: 'dev',
experimentalPtySourceCreditV1: true
} as never)
expect(store.getSshTarget('ssh-source-credit-on')).not.toHaveProperty(
'experimentalPtySourceCreditV1'
)
store.flush()
const persisted = readDataFile() as { sshTargets?: Record<string, unknown>[] }
const target = persisted.sshTargets?.find((entry) => entry.id === 'ssh-source-credit-on')
expect(target).not.toHaveProperty('experimentalPtySourceCreditV1')
})
it('upserts ~/.ssh/config through the real store: rotated port updates in place and persists', async () => {
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
const candidate = (port: number, id: string) => [
@@ -1563,7 +1583,6 @@ describe('Store', () => {
expect(inserted).toHaveLength(1)
expect(inserted[0]?.source).toBe('ssh-config')
expect(inserted[0]?.port).toBe(2200)
// Rotated port: upsert updates the same target in place and normalizeSshTarget must keep `source` (no false re-derive into a permanently-dirty state).
sshConfigHostsToTargetsMock.mockReturnValue(candidate(2222, 'ssh-cfg-2'))
const changed = sshStore.importFromSshConfig()
+2
View File
@@ -1113,6 +1113,7 @@ function backfillLegacyAutomationContexts(
type LegacySshTarget = SshTarget & {
remoteWorkspaceSyncEnabled?: unknown
remoteWorkspaceSyncGracePeriodSeconds?: unknown
experimentalPtySourceCreditV1?: unknown
}
// Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G.
@@ -1127,6 +1128,7 @@ function normalizeSshTarget(t: SshTarget): SshTarget {
delete target.remoteWorkspaceSyncGracePeriodSeconds
delete target.relayGracePeriodSeconds
delete target.systemSshConnectionReuse
delete target.experimentalPtySourceCreditV1
// Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration.
const relayGracePeriodSeconds =
legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number'

Some files were not shown because too many files have changed in this diff Show More