mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
snapshot: terminal session authority construction tree (unshippable, for salvage)
Preserves the full uncommitted construction tree from the prior agent's comprehensive redesign so it can be mined during decomposition. Not intended to ship: +60.9k net production LOC, 0/8 goalposts, 0/13 journeys. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -245,6 +245,59 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
ssh-docker-terminal-authority:
|
||||
name: ssh docker terminal authority
|
||||
needs: build
|
||||
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority-restart.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 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's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
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: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
|
||||
- name: Run Docker SSH terminal authority E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-authority
|
||||
|
||||
- name: Upload terminal authority traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-ssh-docker-terminal-authority
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
|
||||
@@ -101,6 +101,9 @@ docs/**
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/terminal-session-authority.md
|
||||
!docs/reference/terminal-session-authority-delivery.md
|
||||
!docs/reference/terminal-session-authority-handoff.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Why: direct-hook unit calls cannot show ordering. These drive a real Vite write path to prove
|
||||
// a rejected preload leaves nothing on disk while a clean one still emits.
|
||||
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { build } from 'vite'
|
||||
import { createPreloadSandboxBuiltinGuardPlugin } from './preload-sandbox-builtin-guard'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })))
|
||||
})
|
||||
|
||||
async function buildPreload(source: string): Promise<{ outDir: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-preload-guard-'))
|
||||
roots.push(root)
|
||||
const outDir = join(root, 'out')
|
||||
await writeFile(join(root, 'preload.ts'), source, 'utf8')
|
||||
await build({
|
||||
root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
outDir,
|
||||
// Matches the preload target: builtins stay external instead of being browser-polyfilled.
|
||||
ssr: true,
|
||||
write: true,
|
||||
emptyOutDir: true,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
input: { preload: join(root, 'preload.ts') },
|
||||
external: ['electron'],
|
||||
output: { format: 'cjs', entryFileNames: '[name].js' },
|
||||
plugins: [createPreloadSandboxBuiltinGuardPlugin()]
|
||||
}
|
||||
}
|
||||
})
|
||||
return { outDir }
|
||||
}
|
||||
|
||||
describe('preload sandbox guard on a real build write path', () => {
|
||||
it('leaves no output on disk when the preload imports a Node builtin', async () => {
|
||||
const attempt = buildPreload(
|
||||
"import { join } from 'node:path'\nimport { contextBridge } from 'electron'\n" +
|
||||
"contextBridge.exposeInMainWorld('api', { join })\n"
|
||||
)
|
||||
await expect(attempt).rejects.toThrow('node:path')
|
||||
await expect(readdir(join(roots.at(-1)!, 'out'))).rejects.toThrow(/ENOENT/)
|
||||
}, 60_000)
|
||||
|
||||
it('writes a preload whose only external is electron', async () => {
|
||||
const { outDir } = await buildPreload(
|
||||
"import { contextBridge } from 'electron'\n" +
|
||||
"contextBridge.exposeInMainWorld('api', { encode: (v: string) => new TextEncoder().encode(v) })\n"
|
||||
)
|
||||
await expect(readdir(outDir)).resolves.toContain('preload.js')
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Rollup } from 'vite'
|
||||
import {
|
||||
assertPreloadSandboxBundleSafe,
|
||||
assertPreloadSandboxChunkSafe,
|
||||
createPreloadSandboxBuiltinGuardPlugin,
|
||||
findPreloadSandboxViolations
|
||||
} from './preload-sandbox-builtin-guard'
|
||||
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
|
||||
function chunk(fileName: string, code: string, imports: string[] = []): OutputChunk {
|
||||
return {
|
||||
type: 'chunk',
|
||||
fileName,
|
||||
code,
|
||||
imports,
|
||||
dynamicImports: []
|
||||
} as unknown as OutputChunk
|
||||
}
|
||||
|
||||
function runGuard(
|
||||
plugin: ReturnType<typeof createPreloadSandboxBuiltinGuardPlugin>,
|
||||
bundle: Record<string, OutputChunk>
|
||||
): void {
|
||||
const hook = plugin.generateBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('guard plugin must expose a generateBundle hook')
|
||||
}
|
||||
hook.call({ meta: { watchMode: true } } as never, {} as never, bundle as never, false)
|
||||
}
|
||||
|
||||
describe('preload sandbox guard detection', () => {
|
||||
it('detects static CommonJS and ESM Node builtin requests', () => {
|
||||
expect(
|
||||
findPreloadSandboxViolations(
|
||||
'require("node:util"); import("node:path");\nimport value from "node:buffer";\nimport "node:fs"'
|
||||
)
|
||||
).toEqual([
|
||||
{ kind: 'node-builtin', detail: 'node:buffer' },
|
||||
{ kind: 'node-builtin', detail: 'node:fs' },
|
||||
{ kind: 'node-builtin', detail: 'node:path' },
|
||||
{ kind: 'node-builtin', detail: 'node:util' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects bundler-renamed require shims', () => {
|
||||
expect(findPreloadSandboxViolations('var x = __require("fs")')).toEqual([
|
||||
{ kind: 'node-builtin', detail: 'fs' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects non-literal require and import requests', () => {
|
||||
const violations = findPreloadSandboxViolations(
|
||||
'const m = require(name);\nconst d = await import(specifier)'
|
||||
)
|
||||
expect(violations.map((violation) => violation.kind)).toEqual([
|
||||
'nonliteral-module-request',
|
||||
'nonliteral-module-request'
|
||||
])
|
||||
expect(violations.map((violation) => violation.detail)).toEqual([
|
||||
'import(specifier)',
|
||||
'require(name);'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a bare npm external that a sandboxed preload cannot load', () => {
|
||||
expect(findPreloadSandboxViolations('require("zod")')).toEqual([
|
||||
{ kind: 'unsupported-external', detail: 'zod' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an emitted Rollup helper chunk', () => {
|
||||
expect(findPreloadSandboxViolations('require("./chunks/shared-abc123.js")')).toEqual([
|
||||
{ kind: 'helper-chunk', detail: './chunks/shared-abc123.js' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a shared chunk reported by its bare emitted path', () => {
|
||||
expect(() =>
|
||||
assertPreloadSandboxChunkSafe(
|
||||
chunk('index.js', 'export {}', ['chunks/chunk-BTjIgr6M.js', 'ssh2'])
|
||||
)
|
||||
).toThrow(
|
||||
'unloadable emitted chunks: chunks/chunk-BTjIgr6M.js; unsupported bare externals: ssh2'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the explicit Electron preload surface and browser-safe code', () => {
|
||||
expect(
|
||||
findPreloadSandboxViolations(
|
||||
'require("electron"); const bytes = new TextEncoder().encode("safe")'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects Electron subpath exports the emitted preload never uses', () => {
|
||||
expect(findPreloadSandboxViolations('require("electron/renderer")')).toEqual([
|
||||
{ kind: 'unsupported-external', detail: 'electron/renderer' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not flag identifiers or quoted data that merely look like module requests', () => {
|
||||
expect(
|
||||
findPreloadSandboxViolations(
|
||||
'const api = { importPetsFrom: () => ipc.invoke("a"), importPetBundle: () => ipc.invoke("b") };\n' +
|
||||
'const label = "import x from \'node:fs\'";\nconst got = cache.require("node:fs")'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('preload sandbox guard assertions', () => {
|
||||
it('names every offending specifier and the supported surface', () => {
|
||||
expect(() =>
|
||||
assertPreloadSandboxBundleSafe('index.js', 'require("node:util"); require("zod")')
|
||||
).toThrow(
|
||||
/"index\.js" requests unsupported Node builtins: node:util; unsupported bare externals: zod\..*resolves only electron;/s
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a resolved external that never appears in the emitted source', () => {
|
||||
expect(() =>
|
||||
assertPreloadSandboxChunkSafe(chunk('index.js', 'export {}', ['node:crypto']))
|
||||
).toThrow('node:crypto')
|
||||
})
|
||||
|
||||
it('accepts a chunk whose only resolved import is electron', () => {
|
||||
expect(() =>
|
||||
assertPreloadSandboxChunkSafe(chunk('index.js', 'var e = require("electron")', ['electron']))
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preload sandbox guard plugin', () => {
|
||||
it('fails in generateBundle, before Rollup writes the unsafe output', () => {
|
||||
const plugin = createPreloadSandboxBuiltinGuardPlugin()
|
||||
expect(plugin.writeBundle).toBeUndefined()
|
||||
expect(() =>
|
||||
runGuard(plugin, { 'index.js': chunk('index.js', 'require("node:path")') })
|
||||
).toThrow('node:path')
|
||||
})
|
||||
|
||||
it('guards only the included outputs when the build also emits Node entries', () => {
|
||||
const plugin = createPreloadSandboxBuiltinGuardPlugin({
|
||||
include: ['browser-window-close-preload.js']
|
||||
})
|
||||
expect(() =>
|
||||
runGuard(plugin, {
|
||||
'index.js': chunk('index.js', 'require("node:fs")', ['node:fs']),
|
||||
'browser-window-close-preload.js': chunk(
|
||||
'browser-window-close-preload.js',
|
||||
'var { contextBridge } = require("electron")',
|
||||
['electron']
|
||||
)
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('guards the second sandboxed preload emitted by the main build', () => {
|
||||
const plugin = createPreloadSandboxBuiltinGuardPlugin({
|
||||
include: ['browser-window-close-preload.js']
|
||||
})
|
||||
expect(() =>
|
||||
runGuard(plugin, {
|
||||
'index.js': chunk('index.js', 'require("node:fs")'),
|
||||
'browser-window-close-preload.js': chunk(
|
||||
'browser-window-close-preload.js',
|
||||
'require("node:os")'
|
||||
)
|
||||
})
|
||||
).toThrow('browser-window-close-preload.js')
|
||||
})
|
||||
|
||||
it('fails when a guarded preload output is renamed out of the bundle', () => {
|
||||
const plugin = createPreloadSandboxBuiltinGuardPlugin({
|
||||
include: ['browser-window-close-preload.js']
|
||||
})
|
||||
expect(() =>
|
||||
runGuard(plugin, { 'index.js': chunk('index.js', 'require("electron")') })
|
||||
).toThrow('guarded preload output missing from the bundle: browser-window-close-preload.js')
|
||||
})
|
||||
|
||||
it('fails watch rebuilds too, so no dev out/ tree holds an unsafe preload', () => {
|
||||
const plugin = createPreloadSandboxBuiltinGuardPlugin()
|
||||
expect(() =>
|
||||
runGuard(plugin, { 'index.js': chunk('index.js', 'require("node:path")') })
|
||||
).toThrow('node:path')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { builtinModules } from 'node:module'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
||||
type OutputBundle = Rollup.OutputBundle
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
|
||||
const SANDBOX_UNSUPPORTED_BUILTINS = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map((moduleName) => `node:${moduleName}`)
|
||||
])
|
||||
|
||||
/** The only module a sandboxed preload can resolve; everything else throws at load. */
|
||||
const SANDBOX_SUPPORTED_EXTERNALS = new Set(['electron'])
|
||||
|
||||
// `[\w$]*require` also catches bundler-renamed shims such as `__require("fs")`.
|
||||
const LITERAL_MODULE_CALL_RE = /(?<![\w$.])(?:[\w$]*require|import)\s*\(\s*(['"])([^'"]*)\1\s*\)/g
|
||||
const NONLITERAL_MODULE_CALL_RE =
|
||||
/(?<![\w$.])(?:[\w$]*require|import)\s*\((?!\s*(['"])[^'"]*\1\s*\))/g
|
||||
// Anchored at statement position so a quoted `from` inside bundled data can't match.
|
||||
const STATIC_MODULE_SOURCE_RE =
|
||||
/(?:^|[;}])\s*(?:import|export)\s+(?:[^'";()]*?\bfrom\s*)?(['"])([^'"]*)\1/gm
|
||||
|
||||
export type PreloadSandboxViolationKind =
|
||||
| 'node-builtin'
|
||||
| 'unsupported-external'
|
||||
| 'helper-chunk'
|
||||
| 'nonliteral-module-request'
|
||||
|
||||
export type PreloadSandboxViolation = Readonly<{
|
||||
kind: PreloadSandboxViolationKind
|
||||
/** The module specifier, or the call source for a non-literal request. */
|
||||
detail: string
|
||||
}>
|
||||
|
||||
const VIOLATION_LABELS: Record<PreloadSandboxViolationKind, string> = {
|
||||
'node-builtin': 'unsupported Node builtins',
|
||||
'unsupported-external': 'unsupported bare externals',
|
||||
'helper-chunk': 'unloadable emitted chunks',
|
||||
'nonliteral-module-request': 'non-literal module requests'
|
||||
}
|
||||
|
||||
function classifySpecifier(specifier: string): PreloadSandboxViolation | null {
|
||||
if (SANDBOX_SUPPORTED_EXTERNALS.has(specifier)) {
|
||||
return null
|
||||
}
|
||||
if (SANDBOX_UNSUPPORTED_BUILTINS.has(specifier)) {
|
||||
return { kind: 'node-builtin', detail: specifier }
|
||||
}
|
||||
// Rollup names a shared chunk by its emitted path, which is bare relative to the output dir.
|
||||
if (/^[./]/.test(specifier) || /\.[cm]?js$/.test(specifier)) {
|
||||
return { kind: 'helper-chunk', detail: specifier }
|
||||
}
|
||||
return { kind: 'unsupported-external', detail: specifier }
|
||||
}
|
||||
|
||||
function collect(
|
||||
violations: Map<string, PreloadSandboxViolation>,
|
||||
violation: PreloadSandboxViolation | null
|
||||
): void {
|
||||
if (violation) {
|
||||
violations.set(`${violation.kind}:${violation.detail}`, violation)
|
||||
}
|
||||
}
|
||||
|
||||
export function findPreloadSandboxViolations(code: string): PreloadSandboxViolation[] {
|
||||
const violations = new Map<string, PreloadSandboxViolation>()
|
||||
for (const pattern of [LITERAL_MODULE_CALL_RE, STATIC_MODULE_SOURCE_RE]) {
|
||||
pattern.lastIndex = 0
|
||||
for (const match of code.matchAll(pattern)) {
|
||||
const specifier = match[2]
|
||||
if (specifier !== undefined) {
|
||||
collect(violations, classifySpecifier(specifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
NONLITERAL_MODULE_CALL_RE.lastIndex = 0
|
||||
for (const match of code.matchAll(NONLITERAL_MODULE_CALL_RE)) {
|
||||
collect(violations, {
|
||||
kind: 'nonliteral-module-request',
|
||||
detail: code
|
||||
.slice(match.index, match.index + match[0].length + 24)
|
||||
.split('\n')[0]
|
||||
.trim()
|
||||
})
|
||||
}
|
||||
return [...violations.values()].sort((left, right) =>
|
||||
`${left.kind}:${left.detail}`.localeCompare(`${right.kind}:${right.detail}`)
|
||||
)
|
||||
}
|
||||
|
||||
function formatViolations(
|
||||
fileName: string,
|
||||
violations: readonly PreloadSandboxViolation[]
|
||||
): string {
|
||||
const grouped = new Map<PreloadSandboxViolationKind, string[]>()
|
||||
for (const violation of violations) {
|
||||
grouped.set(violation.kind, [...(grouped.get(violation.kind) ?? []), violation.detail])
|
||||
}
|
||||
const parts = [...grouped].map(
|
||||
([kind, details]) => `${VIOLATION_LABELS[kind]}: ${details.join(', ')}`
|
||||
)
|
||||
return (
|
||||
`[preload-sandbox-builtin-guard] "${fileName}" requests ${parts.join('; ')}. ` +
|
||||
`A sandboxed preload resolves only ${[...SANDBOX_SUPPORTED_EXTERNALS].join(', ')}; ` +
|
||||
'keep it browser-safe and do not polyfill Node modules.'
|
||||
)
|
||||
}
|
||||
|
||||
export function assertPreloadSandboxBundleSafe(fileName: string, code: string): void {
|
||||
const violations = findPreloadSandboxViolations(code)
|
||||
if (violations.length > 0) {
|
||||
throw new Error(formatViolations(fileName, violations))
|
||||
}
|
||||
}
|
||||
|
||||
/** Rollup's resolved import lists catch externals and shared chunks a source scan can miss. */
|
||||
export function assertPreloadSandboxChunkSafe(chunk: OutputChunk): void {
|
||||
const violations = new Map<string, PreloadSandboxViolation>()
|
||||
for (const specifier of [...chunk.imports, ...chunk.dynamicImports]) {
|
||||
collect(violations, classifySpecifier(specifier))
|
||||
}
|
||||
for (const violation of findPreloadSandboxViolations(chunk.code)) {
|
||||
collect(violations, violation)
|
||||
}
|
||||
if (violations.size > 0) {
|
||||
throw new Error(formatViolations(chunk.fileName, [...violations.values()]))
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreloadSandboxBuiltinGuardPlugin(
|
||||
options?: Readonly<{
|
||||
/** Emitted file names to guard. Every chunk is guarded when omitted. */
|
||||
include?: readonly string[]
|
||||
}>
|
||||
): Plugin {
|
||||
const include = options?.include ? new Set(options.include) : null
|
||||
return {
|
||||
name: 'orca-preload-sandbox-builtin-guard',
|
||||
// Why: generateBundle precedes the write, so no unsafe preload reaches out/ for a later
|
||||
// `pnpm start` or packaging step to pick up — including on watch rebuilds.
|
||||
generateBundle(_options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
for (const item of Object.values(bundle)) {
|
||||
if (item.type === 'chunk' && (!include || include.has(item.fileName))) {
|
||||
assertPreloadSandboxChunkSafe(item)
|
||||
}
|
||||
}
|
||||
// Why: a renamed entry would otherwise silently leave the preload unguarded.
|
||||
const missing = include ? [...include].filter((fileName) => !(fileName in bundle)) : []
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`[preload-sandbox-builtin-guard] guarded preload output missing from the bundle: ${missing.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-04",
|
||||
"updatedAt": "2026-08-06",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -9672,6 +9672,113 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session-authority.hot-path-and-locator-isolation",
|
||||
"title": "Terminal authority hot paths remain read-only and host locators remain isolated",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "relay-authority-provider-contract",
|
||||
"surfaces": [
|
||||
"terminal binding reachability",
|
||||
"exact terminal input",
|
||||
"terminal output publication",
|
||||
"authority workspace namespace location"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh"],
|
||||
"coverageNotes": "A macOS deterministic provider-contract run drives a fake PTY through the real relay handler, lifecycle, registry, service, journal, and authority projection. Pure path-flavor cases cover POSIX, Windows drive, UNC, extended-device aliases, roots, delimiter isolation, floating namespaces, UTF-8 byte bounds, and deterministic namespace hashing without consulting the client filesystem. Native Linux PTY, Windows ConPTY/named-pipe, daemon, paired-runtime, and live SSH transport measurements remain uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12600"],
|
||||
"invariant": "After an exact PTY binding commits, reachability inspection, exact input routing, and ordinary physical output publication must not append, compact, or revise terminal topology. Their retained state and queues remain bounded per PTY. Authority workspace identity is derived only from the authority host's canonical path, with distinct POSIX, Windows, injected-delimiter, and floating keys; canonical paths use their own exact 32 KiB UTF-8 bound rather than the shorter authority-ID bound.",
|
||||
"oracle": "Commit one real durable authority binding, snapshot its revision, journal byte length, and synced-record count, then perform 25,000 direct reachability checks, 25,000 exact input notifications, and 25,000 physical output callbacks. Require each phase below 2,000 ms, observed heap growth below 32 MiB, exact input/output counts, zero pending output or exit queues, no pause or flush timer, zero service mutations, zero added durable writes or bytes, and an unchanged revision. Separately canonicalize POSIX, drive, UNC, and \\\\?\\ aliases, prove alias-key equality and cross-flavor/floating/delimiter inequality, accept exactly 32 KiB, reject the next UTF-8 byte, and derive the namespace directory from the complete host/namespace tuple.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/relay/terminal-session-authority-hot-path.test.ts src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts src/shared/terminal-session-authority-locator.test.ts src/main/session-authority/terminal-session-authority-workspace-locator.test.ts --reporter=verbose"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/relay/terminal-session-authority-hot-path.test.ts",
|
||||
"src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts",
|
||||
"src/shared/terminal-session-authority-locator.test.ts",
|
||||
"src/main/session-authority/terminal-session-authority-workspace-locator.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/relay/terminal-session-authority-hot-path.test.ts",
|
||||
"assertions": [
|
||||
"expect(reachabilityMs).toBeLessThan(PHASE_BUDGET_MS)",
|
||||
"expect(inputMs).toBeLessThan(PHASE_BUDGET_MS)",
|
||||
"expect(outputMs).toBeLessThan(PHASE_BUDGET_MS)",
|
||||
"expect(heapGrowthBytes).toBeLessThan(HEAP_GROWTH_BUDGET_BYTES)",
|
||||
"expect(state.pendingOutputByPty.size).toBe(0)",
|
||||
"expect(mutationSpy).not.toHaveBeenCalled()",
|
||||
"expect(durableWrites).toBe(durableWritesBefore)",
|
||||
"expect(statSync(logPath).size).toBe(logBytesBefore)",
|
||||
"expect(service.snapshotForObserver(observer).revision).toBe(revisionBefore)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts",
|
||||
"assertions": [
|
||||
"expect(terminalAuthorityNamespaceLocatorKey(deviceDrive)).toBe(terminalAuthorityNamespaceLocatorKey(drive))",
|
||||
"expect(new Set(keys).size).toBe(keys.length)",
|
||||
"expect(new TextEncoder().encode(exactBoundary)).toHaveLength(MAX_CANONICAL_PATH_BYTES)",
|
||||
"expect(terminalSessionAuthorityNamespaceDirectory('authority-root', namespace)).toBe(expected)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/terminal-session-authority-locator.test.ts",
|
||||
"assertions": [
|
||||
"terminal authority namespace locator validates host-canonical path flavor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/session-authority/terminal-session-authority-workspace-locator.test.ts",
|
||||
"assertions": ["terminal authority workspace locator uses authority-host realpath"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/terminal-session-authority-hot-path.test.ts src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts src/shared/terminal-session-authority-locator.test.ts src/main/session-authority/terminal-session-authority-workspace-locator.test.ts --reporter=verbose",
|
||||
"durationSeconds": 0.57,
|
||||
"summary": "13/13 passed in two repeated local runs. Worst observed 25,000-operation phases were 3.93 ms reachability, 6.14 ms exact input, and 10.63 ms output, with 2,223,304 bytes heap growth, zero topology writes, and zero pending output queues."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "four deterministic authority hot-path and locator contract files"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Two repeated local macOS runs exist; CI and cross-platform soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The 2 KiB and exact-32-KiB locator assertions are red under the prior generic 1 KiB authority-ID validation and green with locator-specific validation. The mutation, queue, memory, and latency oracles have no retained intentional-break artifact yet."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Each 25,000-operation phase must finish below 2,000 ms; synchronous observed heap growth must remain below 32 MiB; the output/exit queues, pause set, and flush timer must be empty; and service mutation count, synced journal count, journal bytes, and topology revision must remain unchanged. Across two local runs, worst observed phase times were 3.93/6.14/10.63 ms and heap growth was 2.12 MiB, with every topology and queue count at zero."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Run the same measured contract on native Linux and Windows runners.",
|
||||
"Add native PTY/ConPTY and live SSH transport latency artifacts without weakening the deterministic zero-write oracle.",
|
||||
"Retain an intentional-break artifact for topology writes, queue retention, and latency or memory budget violations."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Only macOS executed the measured test; Windows path behavior is exercised through node:path.win32, not a native Windows filesystem.",
|
||||
"The PTY is deterministic and in-process, so native PTY, ConPTY, named-pipe, SSH-channel, and renderer latency are not measured.",
|
||||
"The 2,000 ms phase ceiling is a coarse CI regression guard, not a user-visible input-latency SLO.",
|
||||
"Heap growth is a synchronous process-level observation without forced GC; retained native memory and file descriptors need separate evidence.",
|
||||
"Imported legacy-worker broker routing and durable source-credit/exit checkpoints are outside this gate."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if a hot-path phase mutates or compacts topology, retains an unbounded queue, exceeds its budget without an identified runner fault, or any canonical alias can collide across host path flavors or exceed the declared byte boundary."
|
||||
},
|
||||
{
|
||||
"id": "remote-wire.cross-version-terminal-journey",
|
||||
"title": "A released client and a current server still complete one terminal journey in both skew directions",
|
||||
|
||||
@@ -72,6 +72,17 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
|
||||
})
|
||||
|
||||
it('runs terminal authority Docker coverage for full and targeted E2E', () => {
|
||||
const authorityJob = e2eWorkflow.jobs['ssh-docker-terminal-authority']
|
||||
expect(authorityJob.if).toBe(
|
||||
"inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority-restart.spec.ts')"
|
||||
)
|
||||
const authorityRun = authorityJob.steps.find(
|
||||
(step) => step.name === 'Run Docker SSH terminal authority E2E'
|
||||
)
|
||||
expect(authorityRun.run).toContain('pnpm run test:e2e:ssh-docker-terminal-authority')
|
||||
})
|
||||
|
||||
it('keeps dedicated E2E workflows out of pull request CI', () => {
|
||||
const dedicatedWorkflows = [
|
||||
'golden-e2e-experiment.yml',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-docker-terminal-authority.spec.ts',
|
||||
'tests/e2e/ssh-docker-terminal-authority-restart.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
'config/build-plugins/**/*.test.ts',
|
||||
'config/scripts/**/*.test.ts',
|
||||
'config/scripts/**/*.test.mjs',
|
||||
'tests/tools/**/*.test.mjs',
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Terminal session authority delivery ledger
|
||||
|
||||
This ledger tracks implementation of
|
||||
[`terminal-session-authority.md`](./terminal-session-authority.md). A goalpost is
|
||||
complete only when its production path and its named proof both exist. Focused
|
||||
unit tests, typechecking, or the presence of scaffolding cannot prove a broader
|
||||
goalpost.
|
||||
|
||||
Status values are `not started`, `partial`, and `proven`. Update the evidence
|
||||
column with exact tests or runtime journeys before changing a row to `proven`.
|
||||
|
||||
## Numeric checkpoint
|
||||
|
||||
The final pause snapshot must retain this release classification:
|
||||
|
||||
- goalposts: **0/8 proven, 6/8 partial, 2/8 not started**;
|
||||
- partial: G0, G1, G2, G3, G4, and G6;
|
||||
- not started: G5 and G7;
|
||||
- required journeys: **0/13 proven**.
|
||||
|
||||
Host-authority and E2EE-storage acceptance is recorded separately as a
|
||||
construction milestone. It cannot promote one of these rows or journeys.
|
||||
|
||||
## Goalposts
|
||||
|
||||
| ID | Goalpost | Completion evidence | Status | Current evidence or gap |
|
||||
| --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| G0 | One design contract | Every production path follows the identity, host-boundary, migration, compatibility, and minimal-shape sections of the design | partial | The contract now states host-local identity, host-derived principals, exact challenge/CAS admission, and final-host cursors. Current code still contains duplicated exact-operation clients, validation, transport-specific topology/retry state, and reachable legacy/authority paths, so the production graph does not yet match the four-role design. No audit entry below is runtime proof. |
|
||||
| G1 | One final-host authority | Local, daemon, WSL, direct SSH, nested SSH, paired runtime, and remote server all resolve one final-host namespace and exact binding | partial | The host pause tranche independently accepted proof-derived daemon and SSH transports, namespace-local admission, exact reconnect, cumulative ACK, and authenticated retirement with P0/P1/P2=0. That acceptance is construction evidence only: complete local/WSL/paired/remote and simultaneous multi-host production journeys, reset orchestration, and post-cutover legacy removal remain unproven. |
|
||||
| G2 | Exact operations only | Input, resize, signal, close, output, and exit are fenced by the captured full binding; stale or missing evidence cannot affect a successor | partial | The accepted host tranche covers exact close rejection, failed and partial stop, disconnected replay, and replacement-while-stop-awaits; the final pause receipts include 466 PTY tests, 168 adjacent tests, and 7 exact-close cases. This does not yet prove every input, resize, signal, close, output, and exit path across local, daemon, WSL, SSH, paired, remote, mobile, renderer fallback, and mixed versions. |
|
||||
| G3 | Durable ordered delivery | Snapshot-before-events, held producer pause, replay-before-resume, durable semantic outcomes, durable idempotent main-process app projection, cumulative final-host ACK, renderer snapshot/delta observation, restart resume, and gap resnapshot are production-wired | partial | The accepted host tranche covers independent host/app consumers, proof-derived transports, namespace admission, reconnect, cumulative ACK, and retirement. The app delivery cursor, per-outcome settlement/tombstone path, duplicate proof-key store, and receipt ledger are deleted. Fresh broad validation passed, but full restart, gap, paired/remote, mixed-version, and crash-before-ACK journeys remain unproven, so pause acceptance does not promote G3. |
|
||||
| G4 | One-way legacy cutover | Capability, freeze, exact inventory, plan, validation, one self-contained authority commit, topology attach, and `pty.openClient` run in order without dual-write or destructive inference | partial | Planner, evidence bridge, relay worker primitives, and a pre-admission barrier exist. Self-contained namespace import records now replay without the separate catalog in focused construction tests, but broader validation, production topology attach, and removal of legacy dual-write remain incomplete. |
|
||||
| G5 | Wire and platform compatibility | Both version-skew directions and native macOS, Linux, Windows, WSL, Docker SSH, daemon, paired, remote, folder, floating, and worktree journeys pass | not started | No production journey is proven. Focused current/old terminal-wire tests pass 4/4 and the accepted host tranche covers fail-closed proof/grant behavior in its owned daemon/SSH scope, but live skew across SSH, paired runtime, remote server, and mobile/E2EE surfaces is absent. Native Windows, WSL, glibc-floor Linux, two-host, folder/floating/UNC, and production-scale coverage also remain absent. |
|
||||
| G6 | Simpler production code | No unreachable non-test modules, test fixtures in production trees, duplicate state machines, re-export shims, or legacy reconciliation reachable after cutover | partial | Completed deletion passes removed 3,609 production LOC, 1,980 gross legacy-catalog LOC, 1,861 app-ledger LOC, and 97 test-only seam LOC, while moving 891 fixture LOC out of production. The 2026-08-07 pre-documentation census is still net +60,903 production LOC across 522 files because replacement authority, transport, migration, and legacy paths coexist. Reachability, duplicate-state-machine, one-type-module, and post-cutover deletion audits remain mandatory; the target remains net at or below zero. |
|
||||
| G7 | No regression and reviewable PR | Correctness, latency, throughput, memory, restore, scale, packaging, independent review, readiness review, and final LOC census pass | not started | The narrower pause validation is independently clean, including the repaired 829-file/8,831-test broad run, but the 13 release journeys, real-platform and production-scale proof, convergence/deletion, rebase, final release review, commit, push, and PR do not exist. |
|
||||
|
||||
## Correctness gates
|
||||
|
||||
- Missing transport, timeout, failed inspection, absent projection, and unknown
|
||||
process state never authorize exit, cleanup, takeover, or replacement.
|
||||
- Ambiguous, unavailable, or rejected attach never falls back to spawning a
|
||||
shell; creation requires an explicit fresh pane generation.
|
||||
- Every mutation target is captured before an await and includes authority host,
|
||||
namespace, pane generation, owner incarnation, physical PTY, and PTY
|
||||
incarnation.
|
||||
- An authoritative path never invokes an ID-only provider method or retries an
|
||||
exact rejection through the legacy path.
|
||||
- The final PTY-owning host appends mutation results and semantic outcomes before
|
||||
publication; a consumer ACK happens only after its handler settles.
|
||||
- Authority checkpoints and first-claim boundaries carry complete bounded
|
||||
app-neutral semantic state with event identities; journal compaction cannot
|
||||
make a new consumer lose title, agent, command, bell, review-link, or exit state.
|
||||
- Host connection state is keyed by `authorityHostId`; it is lazily discovered,
|
||||
permits concurrent hosts, and retains no fabricated state for a skipped host.
|
||||
`NamespaceSession`, admission, grant, failure, and retirement state are each
|
||||
keyed by `authorityHostId + namespaceId`.
|
||||
- One bounded stable app/device proof-of-possession identity is shared across
|
||||
host connections. Its process/session nonce is fresh and ephemeral. The host
|
||||
derives the consumer principal from the authenticated proof and
|
||||
`authorityHostId`; a host-wide SSH endpoint credential or client-supplied
|
||||
profile ID cannot identify that principal.
|
||||
- The host-issued challenge is bound to `authorityHostId`, `namespaceId`, the
|
||||
current CAS (`currentCas`), the candidate process/session nonce, `requestId`, and
|
||||
the exact connection grant. Proof requirements are mechanism-neutral and must
|
||||
provide fresh, domain-separated possession, replay, cross-host, and
|
||||
cross-namespace binding. Prefer the durable E2EE keypair only if such a
|
||||
domain-separated proof satisfies the requirements; otherwise use a dedicated
|
||||
signing key, with no silent weakening.
|
||||
- First-install provisioning creates the proof identity once. Established
|
||||
missing, corrupt, noncanonical, or public/private-mismatched identity state
|
||||
fails closed; explicit reset/re-enrollment revokes the old identity and moves
|
||||
its establishment evidence with paired-device credentials.
|
||||
- The establishment marker contains a stable random installation identity, and
|
||||
the current keypair schema contains the same identity. Only the validated
|
||||
pre-marker keypair schema may migrate without a marker. Migration durably
|
||||
stages the exact legacy key material in the current schema before publishing
|
||||
the marker, then promotes that exact stage; every stage, marker, active-file,
|
||||
and replacement-backup crash cut reuses the same key. A current-schema keypair
|
||||
with no marker fails closed. First install and reset publish a bounded
|
||||
non-active staged keypair through one atomic active-file rename, while reset
|
||||
leaves the installation marker unchanged.
|
||||
- Canonical user-data reconciliation compares validated installation, key, and
|
||||
reset-stage lineage rather than raw JSON or mutable registry/outbox bytes.
|
||||
Equivalent lineage no-ops even after mutable lifecycle drift; key,
|
||||
installation, or reset-transaction divergence fails closed.
|
||||
- A stage declares first-install or reset purpose; reset stages bind the exact
|
||||
transaction ID. The ordinary loader can finish only first install with no
|
||||
active keypair. It never promotes a reset stage, and recovery never generates
|
||||
a second key after either kind of stage is durable. The exact reset stage
|
||||
remains after publication until a later durable phase in the sole reset
|
||||
transaction verifies and removes it; no successor-only, activation,
|
||||
publication, or receipt ledger exists.
|
||||
- Identity-lifecycle writes that advance a crash boundary explicitly sync the
|
||||
file before rename and the containing directory afterward where supported.
|
||||
Real POSIX sync failures propagate, unsupported platform/filesystem behavior
|
||||
is explicit, and ordinary secure-file writes retain their rename-only cost.
|
||||
- Identity reset persists one bounded phase/target-intent record before
|
||||
mutation, then requires all final-host retirements, relay revoke
|
||||
acknowledgements, transport closure, and local credential removal in that
|
||||
order before successor publication. It stores the old proof-key public
|
||||
fingerprint, not a global consumer ID, and derives each host-local principal
|
||||
from `authorityHostId`. It stores no per-target receipt; restart reissues
|
||||
idempotent work against the host journal and relay outbox. Old revoke and
|
||||
host-removal APIs cannot bypass this transaction or infer success from a
|
||||
disconnect.
|
||||
- A lost retirement response retries the exact result on its live connection.
|
||||
After host restart, a fresh retirement-specific proof may confirm the derived
|
||||
principal is already absent without re-claiming it. Ordinary inventory
|
||||
absence, disconnect, or a false legacy cancellation result is not an
|
||||
acknowledgement, and no durable transport-grant or retirement-receipt ledger
|
||||
is added.
|
||||
- Consumer claim, handover, ACK, and retirement resolve through that exact
|
||||
authenticated grant and nonce, never a consumer label alone. Partial setup
|
||||
removes every claim, handler, observer, and grant it added. A changed or
|
||||
stale challenge fails closed.
|
||||
- Exact retry idempotence applies to the canonical challenge/proof/request and
|
||||
`requestId` for one host, namespace, and grant: an uncertain response retries
|
||||
that exact request and returns its original result. A host-current snapshot is
|
||||
never blindly adopted, and no app-global committed/pending predecessor pair
|
||||
is used to infer ownership.
|
||||
- After boundary acceptance, the final host durably commits the policy claim and
|
||||
cursor, then installs the connection-bound grant and exact-retry result in the
|
||||
same serialized operation before returning success. A socket-write callback
|
||||
cannot authorize or roll back it. Same-connection uncertainty retries exactly;
|
||||
restart uses a fresh authenticated resume without a durable grant ledger.
|
||||
- The final host attests first claim versus CAS-fenced handover independently for
|
||||
each namespace. The namespace is the admission transaction: a multiplexed
|
||||
connection never performs a best-effort batch that partially rotates durable
|
||||
ownership; failure retains that namespace's incumbent and cannot invalidate
|
||||
another completed namespace grant or install a global failure fence.
|
||||
- A process crash before the durable claim append preserves the incumbent, while
|
||||
a crash after it leaves the claim and cursor recoverable through authenticated
|
||||
resume. In-memory rollback after an earlier durable claim is not sufficient.
|
||||
- For a first claim, the host holds the producer and publishes the complete
|
||||
boundary before the claim. The app commits and explicitly accepts that exact
|
||||
boundary; transport send/flush success cannot substitute for acceptance. An
|
||||
uncertain failure leaves the incumbent unchanged and retries the exact
|
||||
challenge/proof/request without a boundary ledger.
|
||||
- A high-water boundary snapshot is staged while a returning consumer replays
|
||||
its contiguous suffix, then reconciled before the high-water ACK. This
|
||||
replay-before-reconciliation order is not optional. The snapshot is applied
|
||||
immediately only for a host-attested caught-up first claim.
|
||||
- The app has no delivery cursor, settlement ledger, receipt ledger, or global
|
||||
failure fence. The final host owns cumulative cursors; app projection event
|
||||
keys may make projection application idempotent but cannot authorize a host
|
||||
ACK by themselves.
|
||||
- Every correctness-bearing app handler is idempotent by authority event key or
|
||||
durably commits its projection before cumulative host ACK; an arbitrary
|
||||
callback, renderer delivery, or IPC send followed by ACK is not accepted as
|
||||
exactly-once evidence.
|
||||
- Process-memory deduplication cannot prove app-crash replay safety and never
|
||||
authorizes a cumulative host ACK.
|
||||
- Reconnect drains replay while the producer remains explicitly held. Send
|
||||
failure, disconnect, overflow, restart, and gap all preserve recoverability or
|
||||
fail closed.
|
||||
- Producer holds are keyed by `authorityHostId + namespaceId`. Acquisition and
|
||||
producer queue admission are linearizable; a producer cannot pass the gate
|
||||
before that namespace's hold and append inside its snapshot.
|
||||
- Disconnect and reconnect fence generation-owned queues before installing a
|
||||
successor. A non-settling ACK cannot block the successor, reconnect retries
|
||||
automatically under bounded-resource backoff, and I/O timeout never implies
|
||||
ACK, exit, or liveness.
|
||||
- One exact host/namespace/binding has one active source delivery lease. Its
|
||||
token plus provider, client, and owner generations fence every span, credit
|
||||
ACK, replay, cancellation, pause, and exit. A provisional replacement cannot
|
||||
publish, ACK, or mutate; promotion fences the predecessor and its queued bytes
|
||||
before the replacement becomes active, without affecting sibling namespaces.
|
||||
- The bounded byte/scalar-unit credit window is flow control only. Exhaustion
|
||||
bounds retained output and pauses the exact active delivery; elapsed time,
|
||||
window occupancy, or retry count never decides identity, liveness, takeover,
|
||||
quarantine, or replacement.
|
||||
- Backlog delivery uses bounded contiguous pages so a durable projection does not
|
||||
require one full-sync transaction and transport round trip per historical
|
||||
outcome; page commit and cumulative ACK preserve exact order and replay safety.
|
||||
- Page-aware delivery is part of the first shipped namespace-cursor capability;
|
||||
origin peers that lack it never receive those publications in either
|
||||
version-skew direction.
|
||||
- Migration is read-only until one atomic cutover commit. Ambiguous evidence is
|
||||
visible and non-destructive; no timeout or retry count resolves identity.
|
||||
- Post-cutover unresolved recovery rows fence only the exact identities they name;
|
||||
they neither authorize cleanup nor freeze unrelated namespace operations.
|
||||
- Sandboxed preload safety is enforced against emitted bundles in
|
||||
`generateBundle` before output writes. Every preload chunk and the targeted
|
||||
`browser-window-close-preload` entry permits only the exact `electron`
|
||||
external; bare/static or dynamic imports, helper chunks, and literal, renamed,
|
||||
or non-literal `require` forms fail closed. Ordinary main chunks are outside
|
||||
this sandbox boundary.
|
||||
|
||||
## Required journeys
|
||||
|
||||
| Journey | Required oracle | Status | Current evidence or gap |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Local macOS, Linux, and Windows | Same pane/binding/process across renderer and app restart; stale exact operations rejected | not proven | Focused macOS-host tests exist; no complete live local restart journey on all three operating systems. |
|
||||
| Daemon and WSL | Same PTY across client and daemon reconnect/restart boundaries; generation skew fails closed | not proven | Daemon seams and reconnect races have focused coverage; no physical WSL or full daemon-restart journey. |
|
||||
| Lazy discovery and skipped-host restart | An unused host is not probed eagerly; after a restart that skipped it, lazy rediscovery restores only that host's sessions without adopting host-current state | not proven | Host/namespace isolation has deterministic construction tests; no full skipped-host restart with lazy rediscovery. |
|
||||
| Concurrent multi-host connections | Two or more `HostConnection`s keyed by distinct `authorityHostId`s operate concurrently; one host's disconnect, CAS, or failure cannot affect another | not proven | Host-keyed state and sibling isolation have focused tests; no simultaneous live multi-host journey. |
|
||||
| Namespace-partial admission failure | One namespace on a multiplexed connection fails challenge/CAS or grant publication while another commits; only the failed `authorityHostId + namespaceId` is fenced | not proven | Namespace admission and sibling-failure races have focused tests; no complete multiplexed production journey. |
|
||||
| Docker OpenSSH with `MaxSessions=1` | Same remote PID and exact binding across disconnect and client restart; authority restart imports exactly or exposes unresolved recovery without destruction | not proven | Single-host Docker journeys cover reconnect, contention, rejection, and app relaunch; this is pause evidence, not the full release oracle. |
|
||||
| Two independent Docker SSH hosts | Two Docker hosts with distinct `authorityHostId`s run simultaneously; endpoint credentials, principals, sessions, cursors, and failures never cross-contaminate | not proven | Not run. |
|
||||
| Paired client and remote server | Final host remains authoritative across independent client/host updates, pairing reconnects, and remote-runtime restart | not proven | Wire and storage seams have focused tests; no live paired-runtime or remote-server restart journey. |
|
||||
| Git worktree, folder, floating, drive, and UNC namespaces | Stable host-local namespace with no client repository ID or target ID used as identity | not proven | Pure locator tests cover POSIX, drive, UNC, folder, and floating shapes; real drive/UNC and complete workspace journeys are absent. |
|
||||
| Stable proof and exact retry | One bounded app/device proof identity admits fresh process/session nonces; lost challenge responses retry exactly, while altered or host-current state is rejected | not proven | Proof, CAS, retry, disconnect, and retirement tests exist; no complete cross-platform app/device journey. |
|
||||
| Identity reset and re-enrollment | Crash-resumable host retirement, relay acknowledgement, transport closure, local deletion, and atomic successor publication occur in order; offline/old peers remain pending | not proven | The E2EE storage lifecycle is pause-accepted; the ordered multi-host reset and re-enrollment transaction is not implemented. |
|
||||
| Mixed versions in both directions | No unknown opcode or ungranted topology publication; unsupported challenge/grant or mutations remain isolated legacy or fail before mutation | not proven | In-process old-client/new-host and new-client/old-host wire tests exist; live paired/SSH skew and all exchanged surfaces remain unproved. |
|
||||
| Performance and scale | No regression in input/output latency, backpressure, memory, restore, startup, or large-pane behavior | not proven | A bounded 25,000-operation hot-path gate checks writes, queues, heap, and timing; no production-scale cross-platform baseline or full interaction benchmark. |
|
||||
|
||||
## Reduction ledger
|
||||
|
||||
Production-line count is measured against the eventual PR base and reported
|
||||
separately from tests and CI. Net-negative production code is preferred because
|
||||
the authority replaces reconciliation systems; correctness is never weakened to
|
||||
hit the number. The final target is net production LOC at or below zero after
|
||||
rebasing; any positive remainder requires an explicit retained-module and
|
||||
superseded-path audit rather than a generic “new architecture” justification.
|
||||
Construction snapshots use `--no-renames` against the current branch base; the
|
||||
final census is repeated after rebasing onto the PR base.
|
||||
|
||||
Current reduction targets:
|
||||
|
||||
- remove unreachable non-test modules and unused public seams;
|
||||
- move test-only fixtures out of production compilation;
|
||||
- merge one-type and type-only files into their owning domain modules;
|
||||
- use one authority transition/validation implementation;
|
||||
- use one exact-operation client and one mutation-admission implementation;
|
||||
- replace singleton host transport/admission state with one `HostConnection`
|
||||
keyed by `authorityHostId` and one `NamespaceSession` family keyed by
|
||||
`authorityHostId + namespaceId`;
|
||||
- remove app-global committed/pending predecessor transitions and client-supplied
|
||||
consumer identity; retain only one bounded stable app/device proof identity,
|
||||
host-derived principals, and ephemeral process/session/request state;
|
||||
- remove host-current adoption, retry-count identity decisions, and any
|
||||
process-wide failure fence; failures and retirement remain namespace-local;
|
||||
- share the daemon and relay bounded-output machinery;
|
||||
- delete the relay per-outcome pending map and app-side SSH outcome-receipt
|
||||
staging once authenticated namespace cursors carry exit and semantic outcomes;
|
||||
- delete the app-side delivery cursor, per-outcome settlement/tombstone store,
|
||||
receipt ledger, and suffix reconciliation; persist only the bounded proof
|
||||
identity and use the final-host cumulative cursor plus idempotent app
|
||||
projection replay;
|
||||
- remove client-generation and owner-grace outcome ownership from authoritative
|
||||
delivery once host-derived principals and final-host cursors replace it;
|
||||
retain those mechanisms only in the unchanged legacy session path;
|
||||
- delete legacy lease/layout dual-writes and reconnect reconciliation once G4 is
|
||||
production-wired;
|
||||
- remove the elapsed-time terminal-input quarantine from authoritative renderer
|
||||
recovery; unchanged legacy recovery may retain it only before cutover;
|
||||
- delete the separate legacy cutover catalog, receipt-reference events, and
|
||||
suffix reconciliation after the importer commits self-contained namespace
|
||||
records directly to the authority journal;
|
||||
- delete timing windows, quarantine verdicts, retry-count identity decisions,
|
||||
and parallel renderer readiness state replaced by authority facts.
|
||||
|
||||
Next evidence-backed consolidation seeds, not yet counted as completed
|
||||
deletions:
|
||||
|
||||
- move the test-only SSH fixtures
|
||||
`ssh-pty-consumer-session-grant-fixture.ts`,
|
||||
`ssh-terminal-authority-process-fixture.ts`,
|
||||
`ssh-relay-native-deps-install-fixture.ts`, and
|
||||
`ssh-relay-session-test-fixtures.ts` out of the production source tree; a
|
||||
direct import scan finds only test importers;
|
||||
- keep one E2EE key-material equality implementation instead of the identical
|
||||
exported `sameKeyMaterial` definitions in
|
||||
`e2ee-keypair-backup-lineage.ts` and `e2ee-keypair-successor.ts`;
|
||||
- keep one authority-namespace equality implementation instead of repeating
|
||||
the same host-ID/namespace-ID comparison across app outcome, projection,
|
||||
legacy inventory/state/transition, record validation, and subscription
|
||||
registry modules;
|
||||
- extract shared daemon/SSH admission and delivery transitions only where their
|
||||
current production callers and parity tests prove identical semantics;
|
||||
transport connection and encoding mechanics remain in thin adapters.
|
||||
|
||||
Working consolidation budget (a forcing function, never permission to weaken a
|
||||
correctness gate):
|
||||
|
||||
| Retained responsibility | Gross new production target |
|
||||
| ---------------------------------------------------------- | --------------------------: |
|
||||
| Authority identity, transition, persistence, outcomes | <= 5,000 LOC |
|
||||
| Exact-operation and local/daemon/SSH/relay/paired adapters | <= 3,500 LOC |
|
||||
| One bounded legacy importer and physical-worker adoption | <= 4,000 LOC |
|
||||
| Durable app projection and renderer observation controller | <= 2,000 LOC |
|
||||
| Capability negotiation and compatibility isolation | <= 1,000 LOC |
|
||||
| **Total retained new production** | **<= 15,500 LOC** |
|
||||
|
||||
The matching deletion floor is at least 15,500 production LOC from the replaced
|
||||
exact-operation, direct-SSH reconnect/hydration/retry, lease/receipt, renderer
|
||||
reconciliation/quarantine, and transport-specific state-machine paths. Any
|
||||
budget overrun requires a file-by-file responsibility and superseded-path ledger;
|
||||
"architecture" or "compatibility" alone is not a justification.
|
||||
|
||||
Completed construction-tree reductions:
|
||||
|
||||
- removed the in-memory pending-close registry and polling replay state machine;
|
||||
persisted full-binding close intent now enters the shared SSH termination path.
|
||||
- removed 29 rootless or superseded production modules (3,609 LOC), including a
|
||||
renderer authority island that derived ownership from Redux and duplicate
|
||||
eager-delivery machinery;
|
||||
- removed 1,980 gross production LOC of separate legacy-cutover catalog state,
|
||||
store, record, registry, and service code; namespace journal events now carry
|
||||
complete import and acknowledgement evidence;
|
||||
- removed 10 app delivery-cursor, settlement/tombstone, receipt-ledger, and
|
||||
duplicate proof-key modules (1,861 LOC); the retained app projection is
|
||||
event-keyed and does not own an authority cursor;
|
||||
- removed 97 production LOC of registry adapters, in-memory stores, validators,
|
||||
and accepted-write APIs that had only test callers; equivalent fixtures stay
|
||||
in tests when they still prove live production behavior;
|
||||
- moved five test-only fixtures (891 LOC) from production compilation into
|
||||
explicit `__tests__` directories and removed 4,038 LOC of tests that only
|
||||
exercised the deleted systems.
|
||||
|
||||
Pause-documentation census against
|
||||
`a7ffb244e45fee0cb75a129aaa726ce7a2f68845`, using
|
||||
`git diff --numstat --no-renames` plus full line counts for untracked files:
|
||||
|
||||
| Category | Files | Additions | Deletions | Net |
|
||||
| ---------- | ----: | --------: | --------: | ------: |
|
||||
| Production | 522 | 65,357 | 4,454 | +60,903 |
|
||||
| Tests | 268 | 45,936 | 2,995 | +42,941 |
|
||||
| Docs | 3 | 1,361 | 0 | +1,361 |
|
||||
| CI/config | 8 | 382 | 3 | +379 |
|
||||
|
||||
The expanded status has 801 paths: 255 tracked entries including 10 deletions,
|
||||
plus 546 untracked files. HEAD remains the construction base. At
|
||||
2026-08-07T22:04:56Z, `origin/main` was
|
||||
`2396e5e3e583a9dd8d237602372e5b66a780e6ac`, with branch distance 0 ahead and
|
||||
51 behind. No commit, push, rebase, stash mutation, or PR action occurred
|
||||
during pause closure.
|
||||
|
||||
The increased production count reflects concurrent construction before the
|
||||
required app-ledger, duplicate exact-operation, legacy relay/SSH, unreachable
|
||||
file, and compatibility-path deletion passes. It is not an acceptable final PR
|
||||
size.
|
||||
|
||||
The PR cannot open while a new production module has no production importer or
|
||||
while a legacy source of truth remains reachable after authoritative admission.
|
||||
|
||||
## Verification record
|
||||
|
||||
Record only commands run against the converged worktree. Earlier focused runs
|
||||
are useful development evidence but are not final proof.
|
||||
|
||||
### Historical construction evidence
|
||||
|
||||
The date column below uses the America/Los_Angeles calendar date; embedded ISO
|
||||
timestamps use UTC. These rows preserve development history only. None promotes
|
||||
a goalpost, journey, or pause-tranche acceptance status.
|
||||
|
||||
| Date | Scope | Command or artifact | Result |
|
||||
| ---------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-08-06 | Baseline | Tracked diff: 214 files, +16,907/-5,548; untracked authority source also present | Not reviewable; census and consolidation required |
|
||||
| 2026-08-06 | Multi-host audit (`task_48dd7af53ff8`) | Completed audit of host discovery, transport/session keying, restart, concurrent hosts, partial namespace failure, and Docker/paired/remote topologies | Design corrections incorporated: lazy `HostConnection` by `authorityHostId` and namespace-local session state are required; no runtime journey proof is claimed |
|
||||
| 2026-08-06 | Principal audit (`task_225ba542e4c4`) | Completed audit of app/device identity, host-derived principal, challenge/CAS binding, exact retry, and cross-host authentication boundaries | Design corrections incorporated: one bounded proof identity, mechanism-neutral domain-separated proof, and no blind host-state adoption; no security or runtime proof is claimed |
|
||||
| 2026-08-06 | Reachability snapshot | 256 new non-test source files / 34,612 LOC; 224 runtime, 13 type-only, 19 unreachable | G6 remains partial |
|
||||
| 2026-08-06 | First reduction pass | Exact import audit; node/web typecheck; 11 focused files/98 tests; format and stale-import scans | Removed 29 production files/3,609 LOC and 15 obsolete tests/4,038 LOC; one independent worktree-teardown compatibility assertion remains red |
|
||||
| 2026-08-06 | Durable semantic core | 149 authority tests plus 16 daemon/relay outcome tests; focused node typecheck, lint, format, and max-lines | Journal core and explicit consumer retirement pass; production producer/controller wiring remains |
|
||||
| 2026-08-06 | Host physical effects | Five focused files / 44 tests; duplicate-exit stress 5/5; touched lint, format, and diff-check | Restart replay, exact current/imported shutdown, lost-response/concurrent retry, successor fencing, and duplicate-exit idempotency pass; daemon applier remains unwired |
|
||||
| 2026-08-06 | App consumer store | One focused file / 9 tests | Construction evidence only for bounded identity/incarnation persistence; app cursor, settlement, and receipt-ledger deletion remains required, so it is not proof of the target design |
|
||||
| 2026-08-06 | App outcome broker construction | Four focused files / 24 tests | 11 pass, 13 fail; node typecheck fails only in this mid-refactor slice, so G3 is not production-ready |
|
||||
| 2026-08-06 | Daemon authority negotiation | Five focused files / 62 tests; preceding daemon/host durability slice 14 files / 121 tests; touched lint, format, and diff-check | Caller-controlled capability, control/stream agreement, disconnect clearing, and fail-closed create admission pass. Capability remains unrequested and unadvertised until app transport and exact host-effect application are production-wired; node typecheck is red only in the concurrent broker refactor. |
|
||||
| 2026-08-06 | Broker durability correction | Four focused files / 36 tests; scoped lint, format, and max-lines | Invalid boundaries no longer compact durable settlements before rejection; persistence failure leaves memory and delivery unchanged. The app-side settlement ledger remains a scheduled deletion target, so this is construction evidence only. |
|
||||
| 2026-08-06 | Self-contained namespace migration | Focused migration, service, registry, SSH, relay, and authority suites / 244 tests; CLI/web typechecks, scoped standard/native/type-aware lint, format, max-lines, forbidden-symbol audit, and diff-check | Removed 1,980 gross production LOC of catalog state/store/registry/service code. Namespace journals and checkpoints replay complete import evidence, exact retry, acknowledgement, worker routing, and identity-scoped ambiguity fences without a second catalog. Production topology attach and legacy dual-write deletion remain, so G4 stays partial. |
|
||||
| 2026-08-06 | Renderer subscription lifecycle fence | Seven broker/store/preload files / 39 tests; scoped typecheck, lint, format, max-lines, and diff-check | A subscribe admitted before navigation, renderer loss, detach, or failed send cannot resurrect afterward; admitted ACKs and fresh subscriptions still recover. G3 remains partial because durable app projection and final-host transport are not wired. |
|
||||
| 2026-08-06 | Relay authority grace boundary | Real relay subprocess plus focused grace/lifecycle suites, 62 tests; targeted lint, format, and max-lines | Finite grace, detached/no-client time, disconnect, and reconnect do not dispose an authoritative PTY; exact retirement and explicit administrative shutdown still do. The unchanged isolated legacy path retains compatibility grace behavior. |
|
||||
| 2026-08-06 | App outcome controller boundary | Twelve renderer files / 86 tests; web typecheck, scoped default/native/type-aware lint, format, max-lines, and diff-check | Listener-before-snapshot buffering, per-stream ordering, incarnation fencing, async handler settlement, cumulative ACK retry, and canonical duplicate validation pass. Legacy callbacks have no idempotent-projection admission marker and therefore cannot run or ACK; crash-safe projection and host transport remain required. |
|
||||
| 2026-08-06 | App cursor and ledger deletion (`task_12f08f0ddc0a`) | 56 app tests, 24 shared transport tests, 3 materialization tests, 29 side-effect tests, node/web typechecks, scoped lint, format, max-lines, reachability, and a bounded 25,000-event benchmark | Deleted the app delivery cursor, per-outcome settlement/tombstone path, duplicate proof-key store, and receipt ledger: 10 production modules/1,861 LOC removed. The retained app projection is event-keyed rather than a second authority cursor; final-host transport installation remains required. |
|
||||
| 2026-08-06 | Adversarial proof/CAS audit (`task_d5b816fb16ee`) | Read-only daemon, relay/SSH, proof, CAS, identity, and mixed-version audit; seven focused files / 52 tests | No P0. Five P1 blockers remain: unwired production proof transport, boundary/grant admission deadlock, client-selected mixed-version fallback, success publication before admission commit, and non-transactional policy/admission commit. Replay expiry, scoped challenge capacity, canonical encoding, disconnect cleanup, fail-closed identity loading, and mandatory negotiated SSH grants also require correction and proof. |
|
||||
| 2026-08-06 | Construction LOC census | `git diff --numstat --no-renames HEAD` plus untracked-file line counts at 2026-08-07T05:15:52Z, categorized separately for production and tests | Production: 444 files, +58,456/-3,600, net +54,856. Tests: 231 files, +37,595/-2,299, net +35,296. This is a transient construction tree, not completion evidence; G6 and the net-nonpositive PR gate remain unproven. |
|
||||
| 2026-08-06 | Test-only production seam deletion | Production-import search, five focused test files / 30 tests, web typecheck, scoped default/native/type-aware lint, format, max-lines, and diff-check | Removed 97 production LOC across six exports with only test callers. Test-local fixtures preserve the useful registry/cursor behavior; no production entrypoint or compatibility path changed. |
|
||||
| 2026-08-06 | Identity marker/reset decision | Reset prerequisite audit plus keypair, marker, and canonical-user-data migration review | Settled the legacy conflict without a permissive loader: only the pre-marker keypair schema may migrate; current keypairs bind a stable installation identity to an unchanged marker, and first install/reset activate one staged keypair by atomic rename. Production implementation and crash/platform proof remain required. |
|
||||
|
||||
### Final pause acceptance evidence
|
||||
|
||||
This table is populated only from commands completed after the host and E2EE
|
||||
implementations converge. Pause evidence remains narrower than the G0-G7 and
|
||||
13-journey release gates.
|
||||
|
||||
| Timestamp (UTC) | Platform/host | Scope | Exact command or artifact | Exit | Counts/duration | Result |
|
||||
| ------------------------------ | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | ---: | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| 2026-08-07T19:52:41Z–20:01:59Z | macOS arm64 | Host-authority closure | `/tmp/react185-host-authority-closure-acceptance.md` | 0 | 466 PTY + 168 adjacent + 7 exact-close tests; static gates clean | Accepted in owned pause scope; P0/P1/P2=0. |
|
||||
| 2026-08-07 | macOS arm64 | E2EE storage lifecycle | `/tmp/react185-e2ee-storage-acceptance.md` | 0 | 108 storage + 60 remote-userData + 9 regression + 8 injected-Windows tests | Accepted in owned pause scope; P0/P1/P2=0. |
|
||||
| 2026-08-07T20:21:38Z–20:40:06Z | macOS arm64 | Initial combined validation | `/tmp/react185-final-combined-validation.md` | 1 | Prior broad: 827/8,829 with 2 failures; rerun: 828/8,830 with 1 failure | Blocked only by the 30-second SSH scale fixture setup occurring inside the timed assertion. |
|
||||
| 2026-08-07T20:51:09Z–20:54:03Z | macOS arm64 | Test-only scale-fixture correction | `/tmp/react185-broad-ssh-scale-fixture-closure.md` | 0 | Five SSH and five normal-parallel host-effect + SSH passes; broad exit 0 in 57.695 s | Product command, 15,197-entry scale, oracle, and 30-second assertion timeout remained unchanged. |
|
||||
| 2026-08-07T21:00:32Z–21:02:59Z | macOS arm64 | Independent combined closure | `/tmp/react185-final-combined-validation-closure.md` | 0 | Broad: 829 files/8,831 tests passed; 7/31 skipped; 57.310 s | Pause validation accepted; P0/P1/P2=0. Release goalposts and journeys remain unchanged. |
|
||||
| 2026-08-07 | macOS arm64 | Documentation reconciliation | `/tmp/react185-terminal-authority-docs-audit.md`; `/tmp/react185-terminal-authority-docs-closure.md` | 0 | 3 documents/1,361 lines; four P2s corrected; final P0/P1/P2=0 | Accepted only for the pause boundary; release classifications and gates remain unchanged. |
|
||||
|
||||
## PR gate
|
||||
|
||||
Before committing or opening the PR:
|
||||
|
||||
1. Every G0-G7 row is `proven` with current evidence.
|
||||
2. The required-journey matrix is fully proven on the converged tree.
|
||||
3. Production, test, documentation, and CI LOC are reported separately.
|
||||
Net production LOC is at or below zero, or every positive retained module is
|
||||
justified after an independent reachability and duplicate-state-machine
|
||||
audit.
|
||||
4. The capability remains unadvertised until cutover, durable outcomes, exact
|
||||
operations, topology attach, and mixed-version proof are complete.
|
||||
5. Independent repository review and the release-readiness checklist have no
|
||||
unresolved correctness, security, compatibility, or performance findings.
|
||||
6. The multi-host and principal audits remain design evidence only; they do not
|
||||
promote G1, G3, G5, or G6 to `proven` without the named production journeys,
|
||||
challenge/grant tests, and deletion/reachability evidence.
|
||||
7. Accepting the host-authority and E2EE-storage pause tranches satisfies none
|
||||
of G0-G7, the 13 journeys, final LOC, platform, performance, review, commit,
|
||||
push, or PR gates by itself.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Terminal session authority continuation handoff
|
||||
|
||||
This handoff is the restart point for the comprehensive terminal-session
|
||||
authority replacement. Read the normative
|
||||
[`design`](./terminal-session-authority.md) first, then the numeric
|
||||
[`delivery ledger`](./terminal-session-authority-delivery.md). This document
|
||||
does not weaken either one.
|
||||
|
||||
## Intended outcome
|
||||
|
||||
Replace PR #12600's containment mechanisms with one final-host authority model:
|
||||
|
||||
- no sliding windows, quarantine verdicts, retry-count identity decisions, or
|
||||
inference from absence;
|
||||
- no duplicate app cursor, settlement ledger, receipt ledger, durable grant
|
||||
ledger, retirement-receipt ledger, or app-global failure fence;
|
||||
- one exact pane-generation/PTY-incarnation binding on the final host;
|
||||
- one active source delivery lease per exact host/namespace/binding, with
|
||||
provider, client, owner, and delivery generations fencing a provisional
|
||||
replacement before it can publish, ACK, or mutate;
|
||||
- separate host physical-effect and authenticated app/device consumers reading
|
||||
the same ordered journal through final-host cumulative cursors;
|
||||
- one bounded proof identity, host-derived principals, and namespace-local
|
||||
admission and retirement;
|
||||
- one ordered, crash-resumable identity reset and re-enrollment transaction;
|
||||
- stage-before-marker E2EE publication and canonical migration equality based
|
||||
on validated identity lineage rather than mutable-file byte equality;
|
||||
- isolated mixed-version legacy behavior, with no authoritative fallback;
|
||||
- sandboxed-preload safety enforced against every emitted preload artifact
|
||||
before build output is written, with `electron` as the sole external;
|
||||
- complete macOS, Linux, Windows, WSL, daemon, SSH, paired-runtime, remote,
|
||||
folder, floating, and version-skew proof;
|
||||
- net production LOC at or below zero after the replacement paths delete the
|
||||
systems they supersede.
|
||||
|
||||
Correctness, security, compatibility, and performance gates cannot be weakened
|
||||
to reach the line-count target. Conversely, passing tests cannot justify leaving
|
||||
parallel sources of truth in production.
|
||||
|
||||
## Numeric progress
|
||||
|
||||
The pause snapshot must preserve these release classifications even after both
|
||||
implementation tranches are accepted:
|
||||
|
||||
- goalposts: **0/8 proven, 6/8 partial, 2/8 not started**;
|
||||
- required journeys: **0/13 proven**;
|
||||
- accepted pause tranches: a construction milestone only, never promotion of a
|
||||
release goalpost or journey.
|
||||
|
||||
## Pause boundary
|
||||
|
||||
The requested pause occurs only after all four gates are closed:
|
||||
|
||||
1. Host authority has a fresh adversarial audit with no unresolved owned-scope
|
||||
finding: independent host/app consumers, proof-derived daemon and SSH
|
||||
transports, namespace-local admission, exact reconnect, cumulative ACK, and
|
||||
authenticated retirement.
|
||||
2. E2EE storage lifecycle has a fresh adversarial audit with no unresolved
|
||||
owned-scope finding: strict identity loading, installation marker,
|
||||
purpose-bound stages, exact successor reuse, durable replacement, canonical
|
||||
lifecycle migration, and strict registry/outbox reset loaders.
|
||||
3. The frozen combined tree passes a fresh independent broad authority run,
|
||||
and that closure reconciles, rather than redundantly reruns, the already
|
||||
fresh correctness, SSH, performance, static, build, reliability, wire,
|
||||
Docker, host, and E2EE receipts.
|
||||
4. The normative design, numeric delivery ledger, and continuation handoff are
|
||||
reconciled to that evidence, formatted, and independently audited.
|
||||
|
||||
The pause does **not** mean the comprehensive change is shippable. Identity
|
||||
reset orchestration, legacy deletion/consolidation, all required journeys,
|
||||
performance proof, rebase, final review, commit, push, and PR creation remain
|
||||
outside this boundary.
|
||||
|
||||
## Repository state
|
||||
|
||||
- Worktree: `/Users/nwparker/orca/workspaces/orca/eye-React-185`
|
||||
- Branch: `nwparker/react185-session-authority`
|
||||
- HEAD and construction base:
|
||||
`a7ffb244e45fee0cb75a129aaa726ce7a2f68845`
|
||||
- `origin/main` at 2026-08-07T22:04:56Z:
|
||||
`2396e5e3e583a9dd8d237602372e5b66a780e6ac`
|
||||
- Branch distance at that snapshot: 0 ahead, 51 behind.
|
||||
- Expanded status before documentation finalization: 801 paths—255 tracked
|
||||
entries including 10 deletions, plus 546 untracked files.
|
||||
- Orchestration run: `run_2bf3603a101a`
|
||||
- The worktree is intentionally dirty and contains concurrent construction
|
||||
changes. Preserve unrelated edits and the exact stash entry
|
||||
`stash@{0}: On nwparker/react185-session-authority: react185 terminal authority architecture probe before current-main rebuild`.
|
||||
- No commit, push, rebase, stash mutation, or PR action occurred during pause
|
||||
closure. None is authorized until every delivery gate passes.
|
||||
|
||||
Categorized working-tree census against the construction base, using
|
||||
`git diff --numstat --no-renames` plus full line counts for untracked files:
|
||||
|
||||
| Category | Files | Additions | Deletions | Net |
|
||||
| ---------- | ----: | --------: | --------: | ------: |
|
||||
| Production | 522 | 65,357 | 4,454 | +60,903 |
|
||||
| Tests | 268 | 45,936 | 2,995 | +42,941 |
|
||||
| Docs | 3 | 1,361 | 0 | +1,361 |
|
||||
| CI/config | 8 | 382 | 3 | +379 |
|
||||
|
||||
Recalculate the categorized diff census and active dispatches after the final
|
||||
documentation audit; the values above are an evidence checkpoint, not a
|
||||
substitute for that final read.
|
||||
|
||||
## Acceptance record
|
||||
|
||||
The final pause update must record each gate as one of:
|
||||
|
||||
- `accepted`: implementation, coordinator inspection, focused verification, and
|
||||
a fresh adversarial audit have no unresolved owned finding;
|
||||
- `implemented, unaccepted`: the implementation worker finished but independent
|
||||
review found or has not excluded a blocker;
|
||||
- `not implemented`.
|
||||
|
||||
| Gate | Pause status | Owned scope | Independent audit and exact evidence | Residual limitation |
|
||||
| ----------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Host-authority implementation | accepted | Host/app consumers, proof-derived daemon/SSH transports, namespace admission, reconnect, cumulative ACK, retire | `/tmp/react185-host-authority-closure-acceptance.md`: 466 PTY + 168 adjacent + 7 exact-close tests; P0/P1/P2=0. | No physical Windows, WSL, paired-runtime, remote-server, or two-independent-host proof. |
|
||||
| E2EE storage lifecycle | accepted | Strict identity load, stage/marker/backup recovery, durable replacement, canonical lifecycle migration | `/tmp/react185-e2ee-storage-acceptance.md`: 108 storage + 60 remote-userData + 9 regression + 8 injected-Windows tests; P0/P1/P2=0. | Injected Windows semantics passed; real Windows and physical power-loss durability remain unproved. |
|
||||
| Frozen combined validation | accepted | SSH scale fixture, authority matrix, hot path, reliability, preload/build, wire, Docker, typecheck, lint | `/tmp/react185-final-combined-validation-closure.md`: fresh broad 829 files/8,831 tests passed; prior fresh receipts were reconciled, not rerun. | Full release platform, journey, and production-scale gates remain unproved. |
|
||||
| Documentation package | accepted | Normative design, numeric ledger, continuation handoff, categorized LOC, exact residual gaps | `/tmp/react185-terminal-authority-docs-audit.md`: four P2s corrected; clean final confirmation in `/tmp/react185-terminal-authority-docs-closure.md`. | Pause-only acceptance; release goalposts, journeys, LOC, platform, performance, and review gates remain. |
|
||||
|
||||
`accepted` means only that the named pause gate has sufficient scoped evidence
|
||||
to stop and hand off. It does not mean release-ready and does not satisfy G0-G7,
|
||||
any required journey, the LOC gate, or the final repository/readiness reviews.
|
||||
|
||||
## Platform and provider gaps
|
||||
|
||||
| Surface | Pause evidence | Release gap |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
||||
| macOS | Current development host; focused host and E2EE suites plus injected failure tests. | No complete local production restart/restore journey or power-loss test. |
|
||||
| Docker Linux/OpenSSH | One-host `MaxSessions=1` authority journeys exercise reconnect, contention, rejection, relaunch. | Does not prove a native packaged Linux client, glibc 2.31 floor, two hosts, or an unreliable real network. |
|
||||
| Real Windows | Injected `win32` storage/ACL/rename semantics only. | No Windows filesystem flush, packaged preload, ConPTY, named-pipe, native SSH, or app-restart run. |
|
||||
| Native Linux/glibc floor | Linux OpenSSH runs inside Docker. | No packaged Ubuntu 20.04/glibc 2.31 client or bundled-native-symbol verification in this pause tranche. |
|
||||
| WSL | Path/identity seams have deterministic tests. | No physical WSL daemon, Git Bash/cmd boundary, reconnect, or restart journey. |
|
||||
| Paired runtime | Proof, wire, and projection components have focused tests. | No live paired-runtime reconnect or independently updated peer journey. |
|
||||
| Remote server | Remote `userData` passthrough/isolation has focused tests. | No live remote-server authority, restart, or mixed-version journey. |
|
||||
| Two independent SSH hosts | Host and namespace isolation have focused tests. | No simultaneous two-host Docker journey. |
|
||||
| Mixed versions | In-process current/old terminal-wire test covers both directions. | No live SSH/paired/remote version-skew journey or mobile/E2EE wire proof. |
|
||||
| Power-loss durability | Crash-cut and syscall-failure injection covers stage, marker, active, and backup boundaries. | No physical abrupt-power-loss run, especially on Windows filesystems. |
|
||||
| Performance and scale | Bounded hot-path test measures 25,000 operations with topology-write and heap/queue assertions. | No packaged cross-platform interaction, startup, long-session, or production-scale baseline. |
|
||||
| Folder/floating/drive/UNC | Pure locator and folder/workspace tests cover representative path shapes. | No complete live floating, drive-letter, or UNC workspace journey. |
|
||||
|
||||
## Remaining work in dependency order
|
||||
|
||||
1. **Identity reset and re-enrollment**
|
||||
- Persist the bounded reset record before mutation.
|
||||
- Freeze new pairing and authority admission.
|
||||
- Retire the old proof-derived principal on every known final host.
|
||||
- Wait for every relay revoke outbox acknowledgement.
|
||||
- Close transports, then remove local credentials.
|
||||
- Publish the exact staged successor, invalidate captured proof material,
|
||||
verify through the strict loader, enter re-enrollment, and clear the
|
||||
transaction.
|
||||
- Retry the whole immutable host intent rather than persisting per-target
|
||||
receipts.
|
||||
|
||||
2. **Reachability and deletion audit**
|
||||
- Trace every new production module from a real entrypoint.
|
||||
- Start with the test-only SSH fixtures and duplicate E2EE key-material and
|
||||
authority-namespace equality functions recorded in the delivery ledger;
|
||||
repeat the import scan on the resumed tree before moving or deleting them.
|
||||
- Delete unreachable construction modules, test fixtures in production,
|
||||
duplicate exact-operation clients, and transport-specific state machines.
|
||||
- Prove that app cursors, settlement/receipt ledgers, host-current adoption,
|
||||
quarantine, and timing verdicts are unreachable after authoritative
|
||||
admission.
|
||||
|
||||
3. **Consolidation to the four-role design and LOC reduction**
|
||||
- One final-host service.
|
||||
- Thin exact local/daemon/SSH/WSL/relay/paired adapters.
|
||||
- One app projection/controller.
|
||||
- One bounded legacy importer that disappears as a writer after cutover.
|
||||
- Drive the rebased production diff to net non-positive.
|
||||
|
||||
4. **All runtime, platform, wire, and performance journeys**
|
||||
- Prove all 13 journeys in the delivery ledger.
|
||||
- Use Docker OpenSSH with `MaxSessions=1` and two independent SSH hosts.
|
||||
- Cover daemon, WSL, paired runtime, remote server, folder/floating/UNC,
|
||||
both mixed-version directions, reset crash recovery, and performance.
|
||||
|
||||
5. **Rebase and final categorized census**
|
||||
- Rebase onto current `origin/main` without losing the design invariants.
|
||||
- Re-run the tracked/untracked production, test, docs, CI/config, and other
|
||||
LOC census against the actual PR base.
|
||||
- Reach net-nonpositive production LOC or provide the required file-by-file
|
||||
retained-module and superseded-path justification.
|
||||
|
||||
6. **Independent release review**
|
||||
- Run independent repository and release-readiness reviews on the rebased,
|
||||
fully validated tree.
|
||||
- Resolve every P0-P2 or record an explicit owner-approved release decision
|
||||
where the governing checklist permits one.
|
||||
|
||||
7. **Commit, push, and comprehensive PR**
|
||||
- Only after G0-G7, all 13 journeys, LOC, static, performance, compatibility,
|
||||
and review gates pass, create the commit, push, and one detailed PR.
|
||||
|
||||
## Risks that must remain explicit
|
||||
|
||||
- Replacement and legacy systems coexist during construction; this is not an
|
||||
acceptable final architecture.
|
||||
- A green focused suite is construction evidence, not remote, platform, wire,
|
||||
or power-loss proof.
|
||||
- Remote clients and hosts update independently. New stream semantics require
|
||||
a separately negotiated capability; unknown opcodes may otherwise disappear.
|
||||
- A host-wide SSH credential authenticates transport only. It cannot identify
|
||||
an app/device consumer or key a cursor.
|
||||
- Missing transport, timeout, disconnect, failed inspection, and inventory
|
||||
absence remain unknown. None may authorize cleanup, exit, takeover, or
|
||||
replacement.
|
||||
- Any identity stage, active-file replacement, migration rename, or reset phase
|
||||
that returns success must survive the documented crash boundary.
|
||||
|
||||
## First safe resume commands
|
||||
|
||||
Run these from the worktree before editing:
|
||||
|
||||
```bash
|
||||
orca status --json
|
||||
orca skills get orchestration
|
||||
orca orchestration run-use --id run_2bf3603a101a --json
|
||||
orca orchestration check --json
|
||||
git status --short --branch --untracked-files=all
|
||||
git stash list
|
||||
git rev-list --left-right --count HEAD...origin/main
|
||||
```
|
||||
|
||||
Process and acknowledge any returned delivery before dispatching more work.
|
||||
Release every settled worker before acknowledging its `worker_done`. Never
|
||||
reuse an implementation worker as its own independent auditor.
|
||||
|
||||
## Validation record required at pause
|
||||
|
||||
The full command manifest, UTC windows, durations, counts, and cleanup scan are
|
||||
in `/tmp/react185-final-combined-validation-closure.md`. That closure freshly
|
||||
reran the broad authority tree and reconciled, rather than redundantly reran,
|
||||
the prior fresh receipts below. Each row preserves its exact command and
|
||||
outcome.
|
||||
|
||||
| Scope | Exact command or artifact | Outcome |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Host focused | `pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts --reporter=dot` | Exit 0; 466 passed. The adjacent 168 and exact-close 7 are recorded in `/tmp/react185-host-authority-closure-acceptance.md`; P0/P1/P2=0. |
|
||||
| E2EE storage | `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/e2ee-keypair.test.ts src/main/runtime/mobile-pairing-userdata-path.test.ts src/main/runtime/device-registry.test.ts src/main/runtime/relay/relay-revoke-outbox.test.ts src/main/durable-file-write.test.ts src/main/durable-file-write-syscall-proof.test.ts src/shared/secure-file.test.ts src/shared/secure-file-coarse-ctime.test.ts --reporter=verbose` | Exit 0; 8 files/108 tests. Remote-userData, lifecycle regression, and injected-Windows commands are recorded in `/tmp/react185-e2ee-storage-acceptance.md`; P0/P1/P2=0. |
|
||||
| Broad authority tree | `pnpm exec vitest run --config config/vitest.config.ts --testTimeout=120000 src/main/session-authority src/main/daemon src/main/ssh src/relay src/shared --reporter=dot` | Exit 0; 829 files/8,831 tests passed, 7/31 skipped, 57.310 s. |
|
||||
| Reliability manifest | `pnpm run check:reliability-gates` | Exit 0; 70 gates. |
|
||||
| Reliability contracts | `pnpm exec vitest run --config config/vitest.config.ts config/scripts/check-reliability-gates.test.mjs config/scripts/pr-e2e-gate-contract.test.mjs --reporter=dot` | Exit 0; 2 files/32 tests. |
|
||||
| Wire skew | `ORCA_CROSS_VERSION_BASELINE_REF=v1.4.174 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot` | Exit 0; 4/4. |
|
||||
| Docker SSH | `pnpm run test:e2e:ssh-docker-terminal-authority -- --repeat-each=3` | Exit 0; 12/12 across four `MaxSessions=1` journeys repeated three times. |
|
||||
| Electron build | `pnpm run build:electron-vite` | Exit 0; emitted preload inspection also clean. |
|
||||
| Static gates | `pnpm run typecheck:node`; standard/native/type-aware `oxlint` over the recorded 783-file manifest; `oxfmt --check` over that manifest; `pnpm run check:max-lines-ratchet`; `git diff --check` | Every command exited 0; 352 grandfathered max-lines suppressions and no new bypass. |
|
||||
| Documentation | Fresh format, relative-link, tracked/untracked whitespace, placeholder, categorized-census, and independent audit checks | Exit 0; 3 documents/1,361 lines; four P2s corrected; final P0/P1/P2=0 in `/tmp/react185-terminal-authority-docs-closure.md`. |
|
||||
|
||||
The release-only Windows, WSL, native-glibc, paired/live-remote, two-host,
|
||||
power-loss, folder/floating/UNC, and production-scale gaps remain listed above.
|
||||
No omitted command is inferred from a narrower pass.
|
||||
@@ -0,0 +1,700 @@
|
||||
# Terminal session authority
|
||||
|
||||
Orca treats a terminal pane as one durable logical generation bound to one exact
|
||||
PTY incarnation. The process that owns the PTY also owns this binding and its
|
||||
ordered topology journal. Renderer state, SSH leases, workspace snapshots, and
|
||||
runtime maps are projections; none may infer a replacement binding or destroy a
|
||||
PTY from absence alone.
|
||||
|
||||
This is the implementation contract derived from the SSH authority handover for
|
||||
STA-3077/#11729. It generalizes that diagnosis across local, daemon, WSL, SSH,
|
||||
paired-runtime, and remote-server ownership. It replaces quarantine, elapsed-time
|
||||
windows, retry-count verdicts, and increasingly clever reconciliation; those may
|
||||
remain only inside an unchanged pre-cutover legacy path.
|
||||
|
||||
This contract is the long-term replacement for the containment approaches in
|
||||
PRs #12264, #12743, and #12600. Their incident evidence and non-destructive
|
||||
"unknown is not dead" rule remain inputs; their sliding windows, permanent
|
||||
quarantine verdicts, and peer-store reconciliation are not the target design.
|
||||
|
||||
## Document authority
|
||||
|
||||
This file is the normative design. Implementation must be changed when it
|
||||
contradicts this contract; passing focused tests does not amend the design.
|
||||
|
||||
- [`terminal-session-authority-delivery.md`](./terminal-session-authority-delivery.md)
|
||||
is the proof ledger. It records numeric goalposts, required journeys,
|
||||
reduction targets, and exact verification evidence.
|
||||
- [`terminal-session-authority-handoff.md`](./terminal-session-authority-handoff.md)
|
||||
is the continuation boundary. It records the worktree state, accepted
|
||||
tranches, unresolved risks, dependency order, and first safe resume command.
|
||||
|
||||
No worker report or construction test promotes a goalpost by itself. A delivery
|
||||
row becomes `proven` only when the named production path and its full-scope
|
||||
oracle both exist on the converged tree.
|
||||
|
||||
Accepting the host-authority or E2EE-storage pause tranche is likewise only a
|
||||
construction milestone. It does not promote any G0-G7 release goalpost or any
|
||||
required end-to-end journey in the delivery ledger.
|
||||
|
||||
The multi-host and principal audits (task_48dd7af53ff8 and
|
||||
task_225ba542e4c4) are design inputs, not runtime proof. They require host-local
|
||||
authority and authentication state to remain separate for every discovered host
|
||||
and namespace; this document therefore has no app-global ownership transition
|
||||
or singleton host transport to reconcile.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. One pane generation has at most one live PTY binding.
|
||||
2. One PTY incarnation is bound to at most one pane generation.
|
||||
3. Input, resize, signal, close, output, and exit name the exact binding.
|
||||
4. A stale operation cannot affect a newer pane or PTY incarnation.
|
||||
5. Disconnect, timeout, missing transport, and missing projection mean unknown,
|
||||
never dead.
|
||||
6. A topology mutation is acknowledged only after its operation and result are
|
||||
durable. Retrying before outcome ACK returns that same result; older retries
|
||||
below the durable revision floor fail closed.
|
||||
7. Output and terminal outcomes are ordered. Gaps require a snapshot and never
|
||||
permit silent continuation.
|
||||
8. Renderer mount, unmount, park, and reveal only attach or detach a view.
|
||||
9. Routing facts never become identity. An SSH target, connection generation,
|
||||
pairing revision, WSL distro, or relay hop can admit a request but cannot
|
||||
identify a PTY.
|
||||
10. Ordinary input and output do not write the topology journal.
|
||||
11. Host connection, namespace admission, grant, failure, and retirement state
|
||||
are never singleton or app-global; their keys include the final host and,
|
||||
for namespace state, the host-local namespace.
|
||||
12. A durable app/device identity proves possession of its private key. A
|
||||
process or session nonce is an incarnation fence, not a replacement
|
||||
identity and never authorizes adoption of host state.
|
||||
13. Final-host cumulative cursors are the only delivery cursors. The app has no
|
||||
delivery cursor, settlement ledger, receipt ledger, or global failure fence.
|
||||
14. One exact host, namespace, and PTY binding has at most one active source
|
||||
delivery lease. A provisional replacement cannot publish, ACK, mutate, or
|
||||
consume credit.
|
||||
15. Every source span, credit ACK, cancellation, replay, and exit is fenced by
|
||||
its delivery token plus provider, client, and owner generations. A match on
|
||||
the physical PTY ID alone never admits data.
|
||||
16. Source replacement fences the predecessor before promoting the provisional
|
||||
source. Bytes already queued under the predecessor are rejected after that
|
||||
fence and cannot leak into the successor generation.
|
||||
17. Source activation and failure are namespace-local. Replacing or losing one
|
||||
namespace source cannot pause, publish, ACK, or mutate another namespace.
|
||||
|
||||
## Identity
|
||||
|
||||
The authority host mints `authorityHostId` and `namespaceId`. A namespace is
|
||||
resolved from a host-local locator for a git worktree, folder workspace, or
|
||||
floating workspace. Client-local repository IDs and SSH target IDs are not
|
||||
namespace IDs.
|
||||
|
||||
An exact binding contains:
|
||||
|
||||
```text
|
||||
authorityHostId
|
||||
namespaceId
|
||||
paneKey
|
||||
paneGenerationId
|
||||
ownerIncarnationId
|
||||
physicalPtyId
|
||||
ptyIncarnationId
|
||||
```
|
||||
|
||||
`paneKey` is stable placement identity. `paneGenerationId` distinguishes a pane
|
||||
that was closed and later recreated at the same placement. `ownerIncarnationId`
|
||||
fences a restarted PTY worker. `ptyIncarnationId` fences reuse of a physical PTY
|
||||
ID.
|
||||
|
||||
The app/device has one bounded stable proof-of-possession identity shared across
|
||||
its host connections. It consists of a stable public verification identity and
|
||||
its protected private key; it does not contain a committed/pending predecessor
|
||||
pair, host-current state, delivery cursor, settlement record, or receipt ledger.
|
||||
Each process or session creates a fresh unpredictable nonce and discards it when
|
||||
that process/session ends. A nonce fences an incarnation but cannot be promoted
|
||||
to a new durable identity.
|
||||
|
||||
The security contract is mechanism-neutral. The proof must establish possession
|
||||
of the stable private key, bind a fresh host challenge to its exact context, and
|
||||
provide replay, cross-host, cross-namespace, cross-purpose, and cross-connection
|
||||
separation. The implementation should first evaluate the existing durable E2EE
|
||||
keypair; it may serve this purpose only when a domain-separated proof satisfies
|
||||
all of those requirements. Otherwise it must use a dedicated signing key. A
|
||||
failed fit must not silently weaken the proof or fall back to an unauthenticated
|
||||
client-supplied identifier.
|
||||
|
||||
Identity loading is a security boundary. Explicit first-install provisioning may
|
||||
create the keypair once; after establishment, a missing, malformed, oversized,
|
||||
noncanonical, wrong-version, or public/private-mismatched record fails closed and
|
||||
is never silently regenerated. Loading derives the public key from the private
|
||||
key and compares it with the stored public identity. Recovery is an explicit
|
||||
reset and re-enrollment operation that revokes or retires the old device grants
|
||||
and remote consumer identity before creating a successor. User-data migration
|
||||
moves the keypair, its establishment evidence, and paired-device credentials as
|
||||
one lifecycle unit. Establishment evidence is not a second key store, cursor,
|
||||
receipt, or authority ledger.
|
||||
|
||||
The establishment marker names one random installation identity, not the active
|
||||
consumer key. The current keypair record carries that same installation identity,
|
||||
so rotating the consumer key does not require a second-file activation commit.
|
||||
A validated legacy keypair schema that predates installation identity is the only
|
||||
markerless record eligible for one-way migration. Migration validates and
|
||||
durably stages the current-schema record with the exact legacy key material
|
||||
before publishing its marker, then atomically promotes that same stage. Every
|
||||
crash cut around stage, marker, active-file replacement, and replacement backup
|
||||
reuses that exact staged key; recovery never generates a different identity. A
|
||||
legacy keypair found beside a marker fails closed unless the exact validated
|
||||
migration stage proves that interrupted publication. A current keypair with a
|
||||
missing or mismatched marker also fails closed; neither shape can masquerade as
|
||||
an eligible legacy installation.
|
||||
|
||||
First install writes and validates a non-active staged current-schema keypair,
|
||||
publishes its matching installation marker, then atomically renames the staged
|
||||
record into the active keypair path. Startup may finish only that exact staged
|
||||
publication; it never generates a different key after the marker exists. Reset
|
||||
keeps the installation marker unchanged and, only after all old remote identity
|
||||
retirements and credential revocations complete, atomically replaces the active
|
||||
keypair with a validated staged successor carrying the same installation
|
||||
identity. The staging artifact is bounded, private, and never an accepted proof
|
||||
identity or second logical key store. Canonical user-data migration applies the
|
||||
same rules to the registry, keypair, marker, and any in-progress staging record;
|
||||
a partial target is never made active and the source remains recoverable. If
|
||||
both source and canonical target are complete, migration no-ops only when their
|
||||
validated installation identity, key lineage, and reset-stage binding agree.
|
||||
Equivalent validated lineage, not raw JSON serialization or mutable registry and
|
||||
outbox bytes, decides canonical equality. Two unrelated complete identities are
|
||||
an ambiguity and fail closed. After a proven publication, later source registry
|
||||
or outbox drift does not overwrite or invalidate the canonical target; key,
|
||||
installation, or reset-transaction lineage divergence still fails closed.
|
||||
Each stage declares `first-install` or `reset`; a reset stage is bound to the
|
||||
reset transaction ID and immutable old proof-key public fingerprint. The
|
||||
ordinary loader may finish only a first-install stage when no active keypair
|
||||
exists. It never promotes a reset stage or replaces an
|
||||
active keypair; only the matching transaction in `creating-successor` may reuse
|
||||
and atomically publish that exact stage. Once either stage is durable, recovery
|
||||
never generates a different key. Reset publication retains that exact stage even
|
||||
after it becomes the active keypair. Only a later durable phase of the same sole
|
||||
reset transaction may remove it after verifying that the active and staged key
|
||||
material still match and that the transaction still names the original
|
||||
predecessor. Stage, publish, and finalize retries validate both bindings even
|
||||
after the active path contains the successor. A reused transaction ID with a
|
||||
different predecessor fingerprint fails closed. There is no successor-only,
|
||||
activation, publication, or receipt ledger beside the reset transaction; the
|
||||
retained stage is the bounded crash-recovery artifact.
|
||||
|
||||
## Identity reset and re-enrollment
|
||||
|
||||
Reset is one resumable main-process transaction, not a collection of revoke
|
||||
callbacks. Before any mutation it strictly loads the established identity,
|
||||
marker, device registry, relay revoke outbox, and any existing reset record;
|
||||
freezes new pairing and authority admission; resolves the bounded immutable set
|
||||
of known final hosts; and requires every needed retirement capability. Corrupt,
|
||||
missing, offline, unknown, or incompatible state stops before mutation.
|
||||
|
||||
The sole reset record contains only its version, transaction ID, old proof-key
|
||||
public fingerprint, request time, current phase, and bounded target
|
||||
host/namespace intent. Each host derives its own consumer principal from that
|
||||
proof identity and `authorityHostId`; there is no global consumer ID to persist.
|
||||
The record has no private key, successor key, per-target completion bits, host
|
||||
cursor, receipt, settlement, delivery cursor, or global failure fence. A crash
|
||||
after a subset of acknowledgements reissues the same idempotent retirement to
|
||||
the whole target set; the final-host journal and relay revoke outbox remain the
|
||||
durable acknowledgement sources.
|
||||
|
||||
The phase order is strict and monotonic:
|
||||
|
||||
1. Persist reset intent before any remote or local mutation.
|
||||
2. Reconnect with the old proof identity as needed and durably retire it on
|
||||
every known final host. A timeout, disconnect, or lost response remains
|
||||
unknown; exact retry or fresh authenticated resume must obtain the explicit
|
||||
idempotent retirement result. The same live connection replays its exact
|
||||
cached result. After a host restart, a fresh retirement-specific possession
|
||||
challenge may confirm that the derived host-local principal is already
|
||||
absent without claiming it again; unauthenticated inventory absence cannot.
|
||||
3. Enqueue every old relay binding in the existing revoke outbox and wait until
|
||||
every parsed relay acknowledgement removes its item.
|
||||
4. Close live mobile, paired-runtime, daemon, and SSH transports only after the
|
||||
remote retirement and relay phases complete.
|
||||
5. Atomically remove old local device/runtime credentials. Per-device revoke
|
||||
and mobile host-removal APIs may not bypass this ordering or report success
|
||||
after merely scheduling cleanup.
|
||||
6. Stage, validate, and atomically publish the successor keypair with the same
|
||||
installation identity, while retaining the exact transaction-bound stage.
|
||||
Durably advance the sole reset record past publication, invalidate cached
|
||||
proof material, and recreate any service that captured the old key.
|
||||
7. Verify the successor and retained stage through the strict loader, durably
|
||||
enter the later finalization phase in that same reset record, then remove the
|
||||
stage. Enter re-enrollment and remove the transaction record only after that
|
||||
cleanup boundary. Only this phase may report completion.
|
||||
|
||||
There is no rollback after a host retirement commits. Startup resumes the
|
||||
recorded phase with the old active key until successor publication, and with the
|
||||
new key afterward. Unsupported mixed-version peers remain explicitly pending;
|
||||
disconnect, admission cancellation, legacy revoke, inventory absence, or
|
||||
host-current state never substitutes for authenticated retirement. Settings
|
||||
shows durable phase and pending-host status after confirmation, and closing the
|
||||
dialog cannot cancel a started transaction.
|
||||
|
||||
Every storage mutation whose success advances an identity-lifecycle phase uses
|
||||
an explicit durable write or rename: file sync before publication and containing
|
||||
directory sync afterward where the platform supports it. A real POSIX sync
|
||||
failure propagates instead of being reported as durable success; unsupported
|
||||
platform or filesystem behavior is handled explicitly. This durability is
|
||||
opt-in at lifecycle boundaries. The ordinary secure-file writer keeps its
|
||||
existing rename-only cost, so unrelated credential and settings writes do not
|
||||
inherit synchronous fsync latency.
|
||||
|
||||
## Host boundary
|
||||
|
||||
The authority runs on the final host that owns the managed PTY. An authoritative
|
||||
local or WSL PTY is owned by the stable local terminal daemon; the direct
|
||||
in-process provider remains an isolated legacy path and is never a post-cutover
|
||||
fallback. A direct SSH PTY is owned by the stable service on the SSH host. In
|
||||
nested SSH, a shell on host A running `ssh B` remains an A-owned PTY; an Orca
|
||||
runtime explicitly hosted on B uses B's terminal service and owns its PTYs on B.
|
||||
|
||||
The stable host service owns:
|
||||
|
||||
- namespace resolution and one writer lease epoch per namespace;
|
||||
- pane membership and exact bindings;
|
||||
- idempotent topology operations and their durable results;
|
||||
- PTY workers, bounded replay, and exact input admission;
|
||||
- ordered terminal outcomes and durable consumer cursors.
|
||||
|
||||
Packaged relays and paired runtimes are transport adapters. App upgrades do not
|
||||
restart PTY workers. A compatible newer adapter connects to the existing host
|
||||
service; an incompatible adapter fails before mutation. The service drains or
|
||||
upgrades its control plane only when workers can remain attached.
|
||||
|
||||
`HostConnection` is keyed by `authorityHostId`, not by one process-wide
|
||||
connection slot or by an SSH endpoint string. Discovery is lazy: a configured or
|
||||
previously known host is contacted only when a surface needs it, and an
|
||||
uncontacted or skipped host has no fabricated session, grant, or failure state.
|
||||
Several host connections may be live concurrently. A restart that skipped a host
|
||||
must rediscover that same host before resuming its sessions; it must not adopt
|
||||
host-current state from memory or from another host.
|
||||
|
||||
Reconnect is deterministic in the narrow protocol sense: the same authenticated
|
||||
proof resolves the same final host and namespace, resumes the same durable
|
||||
consumer principal and cumulative cursor, and either reattaches the exact pane
|
||||
generation and PTY incarnation or returns an explicit unresolved/rejected
|
||||
result. Transport loss replaces and fences only the failed connection
|
||||
generation. While a replacement transport is unavailable, work parks or retries
|
||||
with bounded backoff and bounded memory; it never busy-loops, fabricates a new
|
||||
identity, spawns a replacement PTY, adopts host-current state, or lets elapsed
|
||||
time decide liveness. This is the common contract for daemon, SSH, WSL,
|
||||
paired-runtime, and remote-server reconnects; their adapters differ only in how
|
||||
they reach the final host.
|
||||
|
||||
Each `NamespaceSession` and its admission, grant, failure, and retirement state
|
||||
is keyed by the pair `authorityHostId + namespaceId`. A multiplexed
|
||||
`HostConnection` may carry many such sessions, but one namespace's failure can
|
||||
only fail that namespace. There is no process-wide failure fence and no
|
||||
best-effort batch whose success is inferred from a prefix.
|
||||
|
||||
The host derives the consumer principal from the authenticated proof and its own
|
||||
`authorityHostId`. A client-supplied profile/device label is metadata at most;
|
||||
it is never the principal or a cursor key. A host-wide SSH endpoint credential
|
||||
authenticates transport access but cannot identify an app/device consumer. The
|
||||
derived principal is host-local, so the same stable app/device proof yields
|
||||
independent consumer principals on independent hosts.
|
||||
|
||||
For every namespace admission, the host issues a challenge containing exactly:
|
||||
|
||||
```text
|
||||
authorityHostId
|
||||
namespaceId
|
||||
currentCas
|
||||
candidateProcessOrSessionNonce
|
||||
requestId
|
||||
exactConnectionGrant
|
||||
```
|
||||
|
||||
The app proves possession over that canonical challenge. The host verifies the
|
||||
host and namespace, compares the supplied `currentAdmissionCas`, binds the
|
||||
candidate nonce and request to the exact connection grant, and performs the CAS
|
||||
transition atomically. Challenge, proof, request, and result are all scoped to
|
||||
the `authorityHostId + namespaceId` key. A request retry is accepted only when it
|
||||
is the exact same canonical request for the same grant and request ID; it
|
||||
returns the original result idempotently. A changed nonce, CAS, namespace,
|
||||
grant, or request body is rejected. A lost response therefore retries the exact
|
||||
request; it does not fetch and adopt whatever state the host currently reports.
|
||||
|
||||
## Product decisions
|
||||
|
||||
- A session surface is scoped by the final host's namespace locator for a git
|
||||
worktree, folder workspace, or floating workspace. SSH target IDs, client repo
|
||||
IDs, drive spellings, and connection generations are routing aliases only.
|
||||
- Each namespace has one active topology writer epoch. Other devices observe or
|
||||
request handoff; there is no replace-all multi-writer merge.
|
||||
- Detaching a view keeps the pane generation and PTY alive. Closing is an
|
||||
explicit idempotent topology mutation that terminates that exact binding.
|
||||
- Ambiguous, unavailable, or rejected attach never spawns a replacement PTY.
|
||||
Only an explicit create for a fresh pane generation may create a shell.
|
||||
- PTYs may survive with no connected client. Retention cleanup requires an
|
||||
explicit authority operation and exact binding; elapsed time alone never
|
||||
proves death.
|
||||
- Terminal membership, pane generation, binding, and semantic outcome order are
|
||||
host-authoritative. Window placement and other non-terminal chrome remain
|
||||
client-local.
|
||||
- Old clients may use an unchanged, visibly isolated legacy namespace before
|
||||
cutover. After cutover they are read-only when negotiated or rejected before
|
||||
mutation; they cannot open a parallel writable surface.
|
||||
- Writer and input permission is granted to an authenticated actor for a writer
|
||||
epoch and negotiated exact capability. Revocation rotates that authority;
|
||||
routing credentials never become terminal identity.
|
||||
|
||||
## Operations
|
||||
|
||||
Topology mutations carry the authority protocol range, actor and operation IDs,
|
||||
the caller's base revision, writer lease epoch, and exact expected pane/binding.
|
||||
Create, bind, close, supersede, exit, and migration import are idempotent. The
|
||||
authority appends intent, state, and result before publishing the result.
|
||||
Consumer access is not mutation identity: the authenticated actor and writer
|
||||
epoch authorize the operation, while admission separately proves that required
|
||||
consumers and transports are installed. Appending a mutation or semantic outcome
|
||||
does not select, copy to, or advance any consumer.
|
||||
|
||||
Durable append and consumer delivery are separate. A mutation returns after its
|
||||
canonical result is durable; any physical side effect begins only after that
|
||||
commit and can be re-ensured by an idempotent retry. Semantic producers likewise
|
||||
append to the authority journal without waiting for a renderer. Each claimed
|
||||
consumer has one serialized cursor-only delivery pump on the final host; the host
|
||||
advances that consumer's cumulative cursor only after its handler settles. An
|
||||
unavailable consumer therefore retains outcomes until capacity fails closed; it
|
||||
never creates an app-side cursor, settlement queue, receipt ledger, or global
|
||||
failure fence, and never blocks the PTY output hot path on UI availability.
|
||||
|
||||
Input, resize, signal, and close use a separately negotiated exact capability.
|
||||
They are not optional identity fields on a legacy operation: an older adapter
|
||||
could discard those fields and execute the unfenced operation. Exact methods
|
||||
fail closed when either peer did not negotiate them.
|
||||
|
||||
Output uses an attach-time source identity and monotonically ordered spans. The
|
||||
source identity includes a request binding nonce, so concurrent attaches cannot
|
||||
admit an old marker for a new subscription. It is not repeated per frame.
|
||||
|
||||
A source delivery lease is the bounded right to publish one exact PTY
|
||||
incarnation for one namespace. Its identity includes a unique delivery token,
|
||||
the host-service `providerGeneration`, the transport `clientGeneration`, and the
|
||||
physical-worker `ownerGeneration` plus its owner lease. A candidate source stays
|
||||
provisional until the authority has validated its binding and fenced any active
|
||||
predecessor. Promotion first prevents new predecessor reservations and rejects
|
||||
its queued spans, then makes the candidate active; the reverse order is
|
||||
forbidden. ACK, replay, pause, cancellation, exit, and source-credit operations
|
||||
must match the complete active identity.
|
||||
|
||||
The bounded byte/scalar-unit credit window is transport flow control only. It
|
||||
caps unacknowledged source output and memory; it does not wait for stability,
|
||||
vote on identity, decide liveness, quarantine a source, or reproduce PR #12600's
|
||||
elapsed-time sliding-window verdict. Exhausted credit pauses publication until
|
||||
the exact active delivery advances or is explicitly canceled.
|
||||
|
||||
Terminal outcomes such as exit and side-effect facts are appended once to one
|
||||
namespace-global journal before publication. The host derives consumer principals
|
||||
from authenticated app/device proofs; consumer labels name durable effect or
|
||||
policy domains, never transport processes. The host physical-effect consumer is
|
||||
stable for the authority host (`host-effects:<authorityHostId>`), while an
|
||||
app/device consumer is derived independently on each host. Only the
|
||||
process/session nonce rotates on restart. The final host stores one
|
||||
incarnation-fenced cumulative cursor per retained principal, and outcomes are
|
||||
not copied into app-side queues or ledgers. A genuinely new app/device consumer
|
||||
starts at the snapshot boundary, while a returning proof-bearing consumer
|
||||
resumes its final-host cursor after a new process/session nonce is admitted.
|
||||
Duplicate ACKs are idempotent; wrong principals, grants, or nonces are rejected.
|
||||
Mutation and outcome IDs encode the namespace's monotonic revision. Compaction
|
||||
discards only outcomes acknowledged by every retained consumer, retains
|
||||
unacknowledged outcomes as exact replay exceptions, and rejects older IDs at or
|
||||
below the durable revision floor. When unsettled retention is exhausted, exact
|
||||
mutations fail closed for that namespace rather than losing an outcome or
|
||||
freezing unrelated hosts/namespaces.
|
||||
|
||||
The authority checkpoint and boundary materialize the latest bounded,
|
||||
app-neutral semantic facts for every retained pane, including their event
|
||||
identities. A new-at-tail consumer therefore receives current title, agent,
|
||||
command, bell, review-link, and exit state even after older outcomes compact.
|
||||
Consumer-local policy such as dismissing attention remains in that consumer's
|
||||
projection and is never promoted to host topology authority.
|
||||
The materialization retains bounded canonical source outcomes sufficient for
|
||||
each current fact domain and terminal exit, so host restore and app boundary
|
||||
application reuse the same event identities and reducer rather than inventing a
|
||||
parallel semantic state machine or synthetic log.
|
||||
|
||||
The app persists only the bounded stable proof-of-possession identity and its
|
||||
protected key material; it has no app delivery cursor, per-outcome settlement
|
||||
ledger, receipt ledger, or app-global committed/pending handover pair. The
|
||||
final-host cumulative cursor is the delivery source of truth. If the app crashes
|
||||
after applying an idempotent projection but before the host commits its
|
||||
cumulative ACK, replay reapplies the same event key without changing the
|
||||
projection twice. Any process/session nonce and request ID needed for an active
|
||||
attempt are bounded, ephemeral connection state, not a durable predecessor or
|
||||
host-state cache.
|
||||
|
||||
For handover, the app first obtains a host-issued challenge for the exact
|
||||
`authorityHostId + namespaceId` and exact connection grant, including the host's
|
||||
current admission CAS, its fresh process/session nonce, and a request ID. It
|
||||
proves possession of the stable key over that challenge and submits that exact
|
||||
request. A lost response retries the same canonical proof/request and receives
|
||||
the same result; a new challenge is required only after the host explicitly
|
||||
rejects the old one. No host-current snapshot, client-supplied principal, or
|
||||
different request is ever silently adopted as the app's state.
|
||||
|
||||
The final host claims its physical-effect consumer with the current host-service
|
||||
incarnation and installs an exact effect applier before admitting mutations. It
|
||||
idempotently applies authority-owned effects such as a retired binding's shutdown,
|
||||
so a crash between durable close and process termination replays the same work.
|
||||
The applier advances its cursor only after the exact worker accepted shutdown or
|
||||
an exact exit, successor, or host process-identity proof establishes that
|
||||
incarnation complete. A missing session map entry, disconnected adapter, or
|
||||
failed/unavailable inspection is not that proof and leaves the cursor
|
||||
unacknowledged.
|
||||
Each authenticated app or paired device independently claims its host-derived
|
||||
consumer principal before its authoritative requests are admitted. The final host
|
||||
stores and advances that principal's cumulative cursor only after app-scoped
|
||||
policy settles. The host-effect and app/device consumers read the same journal
|
||||
through separate cursor-only pumps; neither copies outcomes nor advances the
|
||||
other's cursor. Admission remains closed until the required consumer, its
|
||||
handler, and its negotiated transport are all installed.
|
||||
|
||||
A host-derived consumer principal is not a bearer credential. Claim, handover,
|
||||
ACK, and retirement are bound to the exact authenticated connection grant and
|
||||
process/session nonce that installed the handler; looking up an operation by a
|
||||
client-supplied consumer label alone is forbidden. The host derives the stable
|
||||
app/device principal from the proof-bearing identity and its own
|
||||
`authorityHostId`, never from a client-supplied profile ID. A host-wide SSH
|
||||
endpoint credential is transport authentication, not an app/device principal,
|
||||
and cannot key or authorize a consumer cursor. Partial connection setup
|
||||
atomically rolls back its claim, handler, observers, and capability grant, and an
|
||||
adapter cannot advertise authoritative mutation until the exact namespace
|
||||
consumer and transport handler are both live.
|
||||
|
||||
Transport admission stages a handover without rotating the durable incumbent.
|
||||
After boundary acceptance, the final host durably commits the policy-consumer
|
||||
claim and cursor. In the same serialized operation it installs the
|
||||
connection-bound grant and exact-retry result before returning success. Failure
|
||||
before the durable append preserves the incumbent. A socket-write callback never
|
||||
authorizes or rolls back that commit: an uncertain response on the same live
|
||||
connection retries the exact challenge/proof/request and returns the original
|
||||
grant. After a host or connection restart, a fresh authenticated challenge
|
||||
resumes the durable claim; it never adopts unauthenticated host-current state or
|
||||
persists a transport token in a second grant ledger. The client validates the
|
||||
exact committed grant before it activates mutation, ACK, or delivery. The
|
||||
negotiated wire grant establishes protocol support but does not by itself
|
||||
authorize a mutation.
|
||||
|
||||
The namespace is the admission transaction; a connection is only an authenticated
|
||||
multiplexing transport. Each namespace stages, publishes, and commits its own exact
|
||||
grant, so failure in one namespace retains that namespace's incumbent without
|
||||
invalidating another namespace's completed grant. A transport must not expose a
|
||||
best-effort multi-namespace batch as active after only a prefix committed.
|
||||
|
||||
Process-memory compensation is not atomicity: a crash before the durable append
|
||||
preserves the incumbent, while a crash after it leaves the consumer claim and
|
||||
cursor available for authenticated resume. This transaction is not an app
|
||||
cursor, receipt ledger, durable transport-grant ledger, or global admission
|
||||
fence.
|
||||
|
||||
For a first claim, the host holds the producer and publishes the complete
|
||||
`new-at-tail` boundary before appending the consumer claim. The app durably
|
||||
commits that caught-up boundary and explicitly accepts its exact identity; a
|
||||
socket write, notification, or IPC-send settlement is not acceptance. The host
|
||||
then verifies the host-issued proof at the same high-water mark, CAS-commits the
|
||||
host-derived principal, and activates delivery. A host-current snapshot never
|
||||
proves that the boundary was accepted; an uncertain failure before the claim
|
||||
leaves the incumbent unchanged and retries the exact challenge/proof/request
|
||||
without a boundary ledger. The app does not treat any host-current state as its
|
||||
own until this per-namespace handshake completes.
|
||||
|
||||
Consumer retention follows authorization, not elapsed time. Revoking a paired
|
||||
device or retiring a host-derived app/device principal is an explicit durable
|
||||
consumer-retirement operation fenced to the exact `authorityHostId`,
|
||||
`namespaceId`, connection grant, and process/session nonce. Only that operation
|
||||
may remove the final-host cursor and release outcomes it had not acknowledged.
|
||||
Disconnect and inactivity alone retain the cursor and therefore eventually apply
|
||||
backpressure rather than lose effects; a failure retires only its namespace
|
||||
session and cannot install a global failure fence.
|
||||
|
||||
An exact close requested while its transport is unavailable is persisted with the
|
||||
complete captured binding. That persisted record is the only pending-close source
|
||||
of truth. Reconnect inventory may confirm the same binding before mutation or
|
||||
positively identify a successor; it cannot replace the captured target. Missing
|
||||
inventory remains unresolved. The close completes only from its durable exit
|
||||
outcome or a positively proven successor, never from polling absence.
|
||||
|
||||
## App and renderer projection
|
||||
|
||||
The main process owns a bounded durable projection per authenticated
|
||||
`authorityHostId + namespaceId` and host-derived principal. It installs the
|
||||
authority subscription before reading the snapshot, holds that namespace's
|
||||
producer while it commits the snapshot and contiguous replay, and then resumes
|
||||
ordered delivery. For each later outcome it transactionally commits the
|
||||
projected state and authority event key before the final host cumulatively ACKs
|
||||
that exact consumer cursor. A crash after projection but before ACK safely
|
||||
replays the same event key. The projection may retain event keys needed to make
|
||||
its rows idempotent, but it cannot retain an app delivery cursor, per-outcome
|
||||
settlement ledger, receipt ledger, or tombstone log that authorizes the host ACK.
|
||||
|
||||
Boundary acceptance names the exact `authorityHostId + namespaceId`, connection
|
||||
grant, process/session nonce, host-derived principal, high-water mark, and
|
||||
boundary identity. It is an idempotent admission handshake, not another delivery
|
||||
cursor or settlement log.
|
||||
|
||||
A boundary projection represents its declared outcome high-water mark. When a
|
||||
returning consumer is behind that mark, the app stages the boundary, replays the
|
||||
contiguous suffix against its prior durable projection before reconciling the
|
||||
staged snapshot with the final replay transaction and ACKing the high-water
|
||||
outcome. This returning replay-before-reconciliation order is mandatory; the
|
||||
boundary must not overwrite events that the suffix has not yet applied.
|
||||
Only a host-attested first claim whose acknowledged sequence already equals the
|
||||
high-water mark may install that snapshot immediately. First claim versus
|
||||
returning handover is attested per namespace by the final host and cannot be
|
||||
inferred from app-global metadata or host-current state.
|
||||
|
||||
The host may deliver a bounded contiguous page and the app commits and
|
||||
cumulatively ACKs that page as one unit. Backlog catch-up must not require one
|
||||
`synchronous=FULL` transaction and one transport round trip per historical
|
||||
outcome; batching never changes event order or the crash-before-ACK contract.
|
||||
Page-aware delivery and page-tail ACKs are part of the first shipped
|
||||
namespace-cursor capability contract. Peers without that whole capability never
|
||||
receive these publications and remain on the isolated legacy path; no permanent
|
||||
branch preserves semantics from an unshipped construction version.
|
||||
|
||||
Producer-hold acquisition is linearizable with producer admission: operations
|
||||
already admitted drain before the snapshot, while no operation can pass a stale
|
||||
no-hold check and enqueue after the hold begins. Release resumes those producers
|
||||
without inserting another ordering queue.
|
||||
|
||||
The app-scoped renderer controller subscribes to committed projection deltas
|
||||
before reading its projection snapshot. React views attach to that controller.
|
||||
Parking removes an expensive view while keeping the controller and projection
|
||||
subscription alive. Renderer delivery, callback completion, mount state, and IPC
|
||||
send success never authorize a final-host ACK; renderer loss is recovered from the
|
||||
committed main-process projection.
|
||||
|
||||
Each authenticated connection generation owns its delivery queue and cancellation
|
||||
signal. Disconnect or reconnect fences that queue before installing a successor,
|
||||
so a non-settling transport ACK cannot serialize work for the next connection.
|
||||
Transport attempts have bounded settlement and reconnect retries automatically
|
||||
with bounded-resource backoff until disposal; those deadlines bound unavailable
|
||||
I/O only and never decide identity, liveness, or whether an outcome was ACKed.
|
||||
|
||||
Title, bell, command completion, PR-link, and exit effects are keyed by authority
|
||||
event sequence. The main-process projection writer must apply each
|
||||
correctness-bearing fact idempotently and durably before ACK. Calling an arbitrary
|
||||
callback and persisting its ACK afterward is not sufficient: a crash between
|
||||
those steps would replay the callback. A sink that cannot prove durable
|
||||
idempotence cannot be installed as an authoritative consumer, and admission stays
|
||||
closed. A process-memory set, map, promise cache, or high-water mark is only an
|
||||
intra-incarnation optimization; it cannot authorize ACK because it disappears in
|
||||
the exact crash-before-ACK case the protocol must recover.
|
||||
|
||||
Renderer state changes such as title, status, command completion, PR-link,
|
||||
attention, and exit are deterministic projections of the durable event. Toasts,
|
||||
sounds, and other presentation cues derive from that committed projection; they
|
||||
never mutate authority or stand in for durable settlement. A remount or process
|
||||
restart can therefore replay without duplicating correctness-bearing state, and
|
||||
an unmounted pane cannot lose it. React lifecycle readiness, elapsed-time
|
||||
windows, and mount pins do not decide terminal liveness or ownership.
|
||||
|
||||
## Migration
|
||||
|
||||
Migration is namespace-scoped and single-writer:
|
||||
|
||||
1. Negotiate authority support and acquire a migration writer epoch.
|
||||
2. Freeze legacy topology writes briefly and inventory live PTYs, local leases,
|
||||
local layout partitions, and the remote workspace snapshot without mutation.
|
||||
3. Import only exact one-to-one pane/PTY matches with incarnation proof.
|
||||
4. Preserve ambiguous or missing matches in a non-destructive legacy recovery
|
||||
surface. Do not attach, expire, kill, or respawn them automatically.
|
||||
5. Commit the import and authority cutover marker in one durable operation.
|
||||
6. Rebuild projections from the authority. Retain legacy backups and tombstones
|
||||
until explicit acknowledgement; never dual-write.
|
||||
|
||||
The bounded importer is a pure reader and planner. Its complete namespace-scoped
|
||||
result—imported bindings, unresolved recovery rows, worker route evidence, and
|
||||
the cutover marker—is one authority log record that can replay without any
|
||||
legacy file. The authority journal never stores a reference into a second
|
||||
catalog log, performs suffix reconciliation from that log, or depends on it to
|
||||
restore a snapshot. An inventory spanning several namespaces produces one
|
||||
independent transaction per namespace while the legacy writer remains frozen;
|
||||
failure leaves that namespace visibly uncut-over rather than partially imported.
|
||||
After a successful cutover, an unresolved row fences only the pane, PTY, or worker
|
||||
identity it names; it does not globally freeze unrelated authoritative operations
|
||||
or consumer admission. Explicit acknowledgement remains an incarnation-fenced
|
||||
namespace operation after clients connect.
|
||||
|
||||
After cutover, clients without the required authority capability cannot mutate
|
||||
the namespace. A compatible read-only observer may attach; otherwise the client
|
||||
must upgrade or use a visibly isolated legacy surface. Rollback cannot silently
|
||||
discard operations committed after cutover.
|
||||
|
||||
## Minimal implementation shape
|
||||
|
||||
The production design has four roles:
|
||||
|
||||
1. One final-host authority service owns namespaces, bindings, PTY workers,
|
||||
mutation results, terminal outcomes, and consumer cursors.
|
||||
2. Local, daemon, SSH, WSL, relay, and paired-runtime adapters expose the same
|
||||
exact operations and carry negotiated capabilities. They do not own another
|
||||
pane or PTY state machine.
|
||||
3. One app-scoped controller consumes snapshot plus ordered events and delivers
|
||||
terminal outcomes. React views are clients of that controller.
|
||||
4. One bounded legacy importer reads old stores before cutover. It cannot mutate
|
||||
an authoritative namespace or remain as a second writer after cutover. Its
|
||||
output is committed directly by the authority; there is no durable migration
|
||||
catalog beside the authority journal.
|
||||
|
||||
These four roles are also the deletion oracle. A production module that only
|
||||
mirrors, translates, validates, caches, retries, or reconciles state already
|
||||
owned by another role must be merged into that owner or deleted. Transport
|
||||
adapters may encode transport mechanics but cannot retain a parallel authority
|
||||
state machine. A net-positive construction tree is therefore unfinished work,
|
||||
not evidence that the replacement needs more layers.
|
||||
|
||||
Compatibility is selected once at admission. An exact operation never retries
|
||||
as an ID-only operation, and an authoritative namespace never falls back to a
|
||||
legacy lease, layout, or provider mutation after admission. Unsupported peers
|
||||
remain on the unchanged isolated legacy path or fail closed before mutation.
|
||||
|
||||
Adding authority beside the old writers is not the end state. Once a path reads
|
||||
its authoritative projection, its former reconciliation, quarantine, retry,
|
||||
dual-write, and timing-verdict code must be removed. Prefer one shared state
|
||||
transition and validation implementation over transport-specific copies.
|
||||
|
||||
## Compatibility and performance
|
||||
|
||||
Negotiation uses protocol major/minor ranges and explicit capabilities, not an
|
||||
app build ID. Unknown required capabilities fail before mutation. The legacy
|
||||
terminal wire remains unchanged for peers that did not negotiate exact authority.
|
||||
Namespace-cursor delivery is likewise capability-negotiated: after negotiation,
|
||||
every delivery carries its authenticated previous sequence and omission fails
|
||||
closed; a peer without that capability remains on the isolated legacy path. The
|
||||
existing exit-only per-outcome delivery capability cannot be reinterpreted as
|
||||
namespace-cursor delivery; the new semantics require a distinct capability
|
||||
version or name.
|
||||
|
||||
The first boundary on that capability carries the complete bounded consumer
|
||||
projection together with its acknowledged sequence and outcome high-water mark;
|
||||
cursor numbers alone are not a snapshot. A decoder may accept an absent optional
|
||||
field to parse an older peer, but authoritative app admission must reject that
|
||||
boundary before ACK or mutation capability is granted.
|
||||
|
||||
Authority admission is O(1). There is no startup-blocking SSH capability probe,
|
||||
per-frame UUID, pane scan, provider listing, or journal write on the input/output
|
||||
hot paths. An unavailable transport uses bounded waits or backoff and cannot
|
||||
schedule an unbounded microtask, timer, promise, listener, log, or allocation
|
||||
loop. Existing latency, backpressure, restore, memory, and scale ceilings may not
|
||||
increase. The release gates cover local, daemon, direct and nested SSH,
|
||||
paired runtime, remote server, folder/floating workspaces, macOS, Linux, Windows,
|
||||
WSL, and both version-skew directions.
|
||||
|
||||
Sandboxed preload compatibility is judged at the emitted artifact boundary, not
|
||||
from the TypeScript import graph. The build guard runs in `generateBundle`
|
||||
before output writes, covers every preload chunk plus the main-build
|
||||
`browser-window-close-preload` entry, and excludes ordinary main-process chunks.
|
||||
Only the exact external module `electron` is permitted. Bare/static imports,
|
||||
dynamic imports, helper chunks, and literal, renamed, or non-literal `require`
|
||||
forms fail closed so an unsafe preload cannot remain in `out/` for a later start
|
||||
or package step.
|
||||
|
||||
Implementation status, deletion targets, and required proof are tracked in
|
||||
[`terminal-session-authority-delivery.md`](./terminal-session-authority-delivery.md).
|
||||
+20
-2
@@ -5,8 +5,12 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
|
||||
import { createPreloadSandboxBuiltinGuardPlugin } from './config/build-plugins/preload-sandbox-builtin-guard'
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
// The sandboxed preload the main config emits; the entry name and the guarded output must agree.
|
||||
const BROWSER_WINDOW_CLOSE_PRELOAD_ENTRY = 'browser-window-close-preload'
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'@xterm/headless',
|
||||
'@xterm/addon-serialize',
|
||||
@@ -208,7 +212,7 @@ export const electronViteConfig: UserConfig = {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
[BROWSER_WINDOW_CLOSE_PRELOAD_ENTRY]: resolve('src/preload/browser-window-close.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
@@ -251,7 +255,15 @@ export const electronViteConfig: UserConfig = {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js'
|
||||
},
|
||||
plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
|
||||
plugins: [
|
||||
createMainBootstrapPlugin(),
|
||||
createPlainNodeEntryGuardPlugin(),
|
||||
// Why: this entry is a sandboxed guest-WebContents preload built by the main
|
||||
// config, so it needs the same browser-safe guarantee as out/preload.
|
||||
createPreloadSandboxBuiltinGuardPlugin({
|
||||
include: [`${BROWSER_WINDOW_CLOSE_PRELOAD_ENTRY}.js`]
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
// Why: compile-time substitution for the telemetry gate. See the block
|
||||
@@ -277,6 +289,12 @@ export const electronViteConfig: UserConfig = {
|
||||
build: {
|
||||
externalizeDeps: {
|
||||
exclude: ['@electron-toolkit/preload']
|
||||
},
|
||||
rollupOptions: {
|
||||
// Electron is the only privileged preload external; bundling the npm
|
||||
// package would pull its Node-only installer (fs/path/child_process).
|
||||
external: ['electron'],
|
||||
plugins: [createPreloadSandboxBuiltinGuardPlugin()]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
|
||||
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
|
||||
"test:e2e:ssh-docker-terminal-authority": "node config/scripts/run-ssh-docker-terminal-authority-e2e.mjs",
|
||||
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
|
||||
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"win-update-e2e": "node tests/tools/win-update-e2e/run.mjs",
|
||||
|
||||
@@ -178,4 +178,16 @@ describe('managed hook owner identity', () => {
|
||||
'linux:pid:[4026533001]:current-boot-id:4242'
|
||||
)
|
||||
})
|
||||
|
||||
it('parses the Windows machine identity without accepting unrelated registry output', async () => {
|
||||
const identity = await import('./managed-hook-owner-identity')
|
||||
expect(
|
||||
identity.parseWindowsMachineGuid(`
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ 00000000-0000-4000-8000-000000000001
|
||||
`)
|
||||
).toBe('00000000-0000-4000-8000-000000000001')
|
||||
expect(identity.parseWindowsMachineGuid('MachineGuid REG_SZ')).toBeNull()
|
||||
expect(identity.parseWindowsMachineGuid('OtherValue REG_SZ machine-a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { link, lstat, mkdir, readFile, readlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
@@ -28,7 +28,7 @@ export function parseLinuxStartTicks(statLine: string): string | null {
|
||||
return startTicks ?? null
|
||||
}
|
||||
|
||||
async function readLinuxPidNamespace(pid: number): Promise<string | undefined> {
|
||||
export async function readLinuxPidNamespace(pid: number | 'self'): Promise<string | undefined> {
|
||||
try {
|
||||
const namespace = await readlink(`/proc/${pid}/ns/pid`)
|
||||
return namespace.trim() || undefined
|
||||
@@ -37,6 +37,29 @@ async function readLinuxPidNamespace(pid: number): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWindowsMachineGuid(output: string): string | null {
|
||||
const machineGuid = /^\s*MachineGuid\s+REG_[A-Z0-9_]+\s+([^\r\n]+)$/im.exec(output)?.[1]?.trim()
|
||||
return machineGuid && machineGuid.length <= 256 ? machineGuid : null
|
||||
}
|
||||
|
||||
async function readWindowsHostIdentity(): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'reg.exe',
|
||||
['QUERY', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'],
|
||||
{ encoding: 'utf8', timeout: 1_000, windowsHide: true }
|
||||
)
|
||||
const machineGuid = parseWindowsMachineGuid(stdout)
|
||||
if (!machineGuid) {
|
||||
return undefined
|
||||
}
|
||||
const digest = createHash('sha256').update(machineGuid).digest('base64url')
|
||||
return `win32-machine:${digest}:pid-namespace:global`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readPublishedHostToken(path: string, uid: number): Promise<string | undefined> {
|
||||
try {
|
||||
const stats = await lstat(path)
|
||||
@@ -112,6 +135,9 @@ async function readHostIdentity(): Promise<string> {
|
||||
return runtimeHostIdentity
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return (await readWindowsHostIdentity()) ?? runtimeHostIdentity
|
||||
}
|
||||
return runtimeHostIdentity
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DaemonClient } from './client'
|
||||
import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson'
|
||||
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY } from './daemon-hello-protocol'
|
||||
|
||||
function createTestDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'daemon-client-test-'))
|
||||
@@ -77,6 +78,8 @@ describe('DaemonClient', () => {
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
}
|
||||
helloCapabilities?: (role: 'control' | 'stream') => unknown
|
||||
helloTail?: (role: 'control' | 'stream') => string
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server = createServer((socket) => {
|
||||
@@ -113,7 +116,7 @@ describe('DaemonClient', () => {
|
||||
return
|
||||
}
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
`${encodeNdjson({
|
||||
type: 'hello',
|
||||
ok: true,
|
||||
...(!opts?.omitHelloIdentity
|
||||
@@ -122,8 +125,11 @@ describe('DaemonClient', () => {
|
||||
? opts.helloIdentity(hello.role)
|
||||
: { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' }
|
||||
}
|
||||
: {}),
|
||||
...(opts?.helloCapabilities
|
||||
? { capabilities: opts.helloCapabilities(hello.role) }
|
||||
: {})
|
||||
})
|
||||
})}${opts?.helloTail?.(hello.role) ?? ''}`
|
||||
)
|
||||
if (hello.role === 'stream') {
|
||||
opts?.onStreamHello?.(hello)
|
||||
@@ -155,6 +161,115 @@ describe('DaemonClient', () => {
|
||||
expect(client.isConnected()).toBe(true)
|
||||
// Both control and stream sockets should have sent hello
|
||||
await waitFor(() => hellos.length > 0)
|
||||
expect(hellos[0]?.capabilities).toBeUndefined()
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(false)
|
||||
})
|
||||
|
||||
it('enables terminal authority only after both sockets echo the exact capability', async () => {
|
||||
const hellos: HelloMessage[] = []
|
||||
await startMockDaemon({
|
||||
onHello: (hello) => hellos.push(hello),
|
||||
helloCapabilities: () => ({
|
||||
terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY
|
||||
})
|
||||
})
|
||||
client = new DaemonClient({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(true)
|
||||
expect(hellos).toHaveLength(2)
|
||||
expect(
|
||||
hellos.every(
|
||||
(hello) =>
|
||||
hello.capabilities?.terminalSessionAuthority ===
|
||||
TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY
|
||||
)
|
||||
).toBe(true)
|
||||
client.disconnect()
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves a stream event coalesced behind the hello response', async () => {
|
||||
const event: DaemonEvent = {
|
||||
type: 'event',
|
||||
event: 'terminalAuthorityNamespaceOutcomeBoundary',
|
||||
sessionId: 'authority-outcomes:namespace-a',
|
||||
payload: {
|
||||
version: 1,
|
||||
consumer: {
|
||||
consumerId: 'app-profile:client-test',
|
||||
consumerIncarnationId: 'app-process:client-test'
|
||||
},
|
||||
namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' },
|
||||
acknowledgedSequence: 0,
|
||||
outcomeHighWatermark: 0,
|
||||
boundaryId: 'boundary-a',
|
||||
consumerStart: 'new-at-tail'
|
||||
}
|
||||
}
|
||||
await startMockDaemon({
|
||||
helloTail: (role) => (role === 'stream' ? encodeNdjson(event) : '')
|
||||
})
|
||||
const received: unknown[] = []
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
client.onEvent((value) => received.push(value))
|
||||
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(received).toEqual([event])
|
||||
})
|
||||
|
||||
it.each([34, 35])('keeps a version-only v%s daemon on the legacy path', async (version) => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion: version,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores unsupported terminal authority capability values', async () => {
|
||||
await startMockDaemon({
|
||||
helloCapabilities: () => ({ terminalSessionAuthority: true })
|
||||
})
|
||||
client = new DaemonClient({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects disagreement between control and stream capabilities', async () => {
|
||||
await startMockDaemon({
|
||||
helloCapabilities: (role) =>
|
||||
role === 'control'
|
||||
? { terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY }
|
||||
: {}
|
||||
})
|
||||
client = new DaemonClient({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
|
||||
await expect(client.ensureConnected()).rejects.toThrow(
|
||||
'Daemon capabilities changed during connection'
|
||||
)
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(false)
|
||||
})
|
||||
|
||||
it('captures one matching endpoint identity from both authenticated sockets', async () => {
|
||||
|
||||
+110
-22
@@ -19,6 +19,13 @@ import type {
|
||||
} from './types'
|
||||
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
|
||||
import { decodeDaemonResponseError } from './daemon-errors'
|
||||
import {
|
||||
CURRENT_DAEMON_HELLO_CAPABILITIES,
|
||||
parseDaemonHelloCapabilities,
|
||||
sameDaemonHelloCapabilities,
|
||||
TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY,
|
||||
type DaemonHelloCapabilities
|
||||
} from './daemon-hello-protocol'
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5000
|
||||
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
|
||||
@@ -28,6 +35,7 @@ export type DaemonClientOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
protocolVersion?: number
|
||||
terminalSessionAuthorityConsumerProofReady?: () => boolean
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
@@ -36,10 +44,17 @@ type PendingRequest = {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type AuthenticatedDaemonHello = Readonly<{
|
||||
identity: DaemonEndpointIdentity | null
|
||||
capabilities: DaemonHelloCapabilities
|
||||
tail: Buffer
|
||||
}>
|
||||
|
||||
export class DaemonClient {
|
||||
private socketPath: string
|
||||
private tokenPath: string
|
||||
private protocolVersion: number
|
||||
private terminalSessionAuthorityConsumerProofReady: () => boolean
|
||||
private clientId = randomUUID()
|
||||
|
||||
private controlSocket: Socket | null = null
|
||||
@@ -57,6 +72,7 @@ export class DaemonClient {
|
||||
private connectingPromise: Promise<void> | null = null
|
||||
private connectionAttemptGeneration = 0
|
||||
private daemonIdentity: DaemonEndpointIdentity | null = null
|
||||
private negotiatedCapabilities: DaemonHelloCapabilities = {}
|
||||
private observedAuthenticatedDisconnect = false
|
||||
|
||||
private pendingRequests = new Map<string, PendingRequest>()
|
||||
@@ -69,6 +85,8 @@ export class DaemonClient {
|
||||
this.socketPath = opts.socketPath
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||
this.terminalSessionAuthorityConsumerProofReady =
|
||||
opts.terminalSessionAuthorityConsumerProofReady ?? (() => false)
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
@@ -83,6 +101,24 @@ export class DaemonClient {
|
||||
return this.observedAuthenticatedDisconnect
|
||||
}
|
||||
|
||||
supportsTerminalSessionAuthority(): boolean {
|
||||
return (
|
||||
this.connected &&
|
||||
this.negotiatedCapabilities.terminalSessionAuthority ===
|
||||
TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY
|
||||
)
|
||||
}
|
||||
|
||||
terminalSessionAuthorityConsumerProofHostId(): string | null {
|
||||
const capability = this.negotiatedCapabilities.terminalAuthorityConsumerProof
|
||||
return capability && 'version' in capability ? capability.authorityHostId : null
|
||||
}
|
||||
|
||||
terminalSessionAuthorityConsumerRetirementSupported(): boolean {
|
||||
const capability = this.negotiatedCapabilities.terminalAuthorityConsumerProof
|
||||
return Boolean(capability && 'version' in capability && capability.retirementVersion === 1)
|
||||
}
|
||||
|
||||
async ensureConnected(): Promise<void> {
|
||||
return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false)
|
||||
}
|
||||
@@ -131,33 +167,45 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
try {
|
||||
const requestedCapabilities = this.requestedHelloCapabilities()
|
||||
// Sequential: control first, then stream
|
||||
const pendingControlSocket = await this.connectSocket(remainingMs())
|
||||
this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket)
|
||||
this.controlSocket = pendingControlSocket
|
||||
const controlIdentity = await this.sendHello(
|
||||
const controlHello = await this.sendHello(
|
||||
this.controlSocket,
|
||||
token,
|
||||
'control',
|
||||
remainingMs()
|
||||
remainingMs(),
|
||||
requestedCapabilities
|
||||
)
|
||||
this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket)
|
||||
pendingListenerCleanups.push(this.setupControlParser(this.controlSocket))
|
||||
pendingListenerCleanups.push(this.setupControlParser(this.controlSocket, controlHello.tail))
|
||||
|
||||
const pendingStreamSocket = await this.connectSocket(remainingMs())
|
||||
this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket)
|
||||
this.streamSocket = pendingStreamSocket
|
||||
const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs())
|
||||
const streamHello = await this.sendHello(
|
||||
this.streamSocket,
|
||||
token,
|
||||
'stream',
|
||||
remainingMs(),
|
||||
requestedCapabilities
|
||||
)
|
||||
this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket)
|
||||
if (!sameDaemonIdentity(controlIdentity, streamIdentity)) {
|
||||
if (!sameDaemonIdentity(controlHello.identity, streamHello.identity)) {
|
||||
throw new DaemonProtocolError('Daemon identity changed during connection')
|
||||
}
|
||||
pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket))
|
||||
if (!sameDaemonHelloCapabilities(controlHello.capabilities, streamHello.capabilities)) {
|
||||
throw new DaemonProtocolError('Daemon capabilities changed during connection')
|
||||
}
|
||||
pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket, streamHello.tail))
|
||||
|
||||
this.assertConnectionAttemptCurrent(attemptGeneration)
|
||||
this.connected = true
|
||||
this.observedAuthenticatedDisconnect = false
|
||||
this.daemonIdentity = controlIdentity
|
||||
this.daemonIdentity = controlHello.identity
|
||||
this.negotiatedCapabilities = controlHello.capabilities
|
||||
this.disconnectArmed = true
|
||||
this.connectionGeneration++
|
||||
|
||||
@@ -184,6 +232,7 @@ export class DaemonClient {
|
||||
this.streamSocket = null
|
||||
this.connected = false
|
||||
this.daemonIdentity = null
|
||||
this.negotiatedCapabilities = {}
|
||||
this.disconnectArmed = false
|
||||
throw error
|
||||
}
|
||||
@@ -259,6 +308,7 @@ export class DaemonClient {
|
||||
this.connectionAttemptGeneration++
|
||||
this.connected = false
|
||||
this.daemonIdentity = null
|
||||
this.negotiatedCapabilities = {}
|
||||
this.disconnectArmed = false
|
||||
this.cleanupActiveSocketListeners()
|
||||
|
||||
@@ -331,18 +381,23 @@ export class DaemonClient {
|
||||
socket: Socket,
|
||||
token: string,
|
||||
role: 'control' | 'stream',
|
||||
timeoutMs: number
|
||||
): Promise<DaemonEndpointIdentity | null> {
|
||||
timeoutMs: number,
|
||||
requestedCapabilities: DaemonHelloCapabilities = this.requestedHelloCapabilities()
|
||||
): Promise<AuthenticatedDaemonHello> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hello: HelloMessage = {
|
||||
type: 'hello',
|
||||
version: this.protocolVersion,
|
||||
token,
|
||||
clientId: this.clientId,
|
||||
role
|
||||
role,
|
||||
...(requestedCapabilities.terminalSessionAuthority !== undefined ||
|
||||
requestedCapabilities.terminalAuthorityConsumerProof !== undefined
|
||||
? { capabilities: requestedCapabilities }
|
||||
: {})
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
let buffer = Buffer.alloc(0)
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const cleanup = (): void => {
|
||||
@@ -354,7 +409,14 @@ export class DaemonClient {
|
||||
socket.removeListener('error', onError)
|
||||
socket.removeListener('close', onClose)
|
||||
}
|
||||
const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => {
|
||||
const finish = (
|
||||
error?: Error,
|
||||
hello: AuthenticatedDaemonHello = {
|
||||
identity: null,
|
||||
capabilities: {},
|
||||
tail: Buffer.alloc(0)
|
||||
}
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
@@ -364,19 +426,17 @@ export class DaemonClient {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(identity)
|
||||
resolve(hello)
|
||||
}
|
||||
// Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes.
|
||||
// Decoding each Buffer independently would permanently inject U+FFFD.
|
||||
const decoder = new StringDecoder('utf8')
|
||||
const onData = (chunk: Buffer): void => {
|
||||
buffer += decoder.write(chunk)
|
||||
const newlineIdx = buffer.indexOf('\n')
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
const newlineIdx = buffer.indexOf(0x0a)
|
||||
if (newlineIdx === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const line = buffer.slice(0, newlineIdx)
|
||||
const line = buffer.subarray(0, newlineIdx).toString('utf8')
|
||||
const tail = buffer.subarray(newlineIdx + 1)
|
||||
try {
|
||||
const response = JSON.parse(line) as HelloResponse
|
||||
if (response.ok) {
|
||||
@@ -388,7 +448,11 @@ export class DaemonClient {
|
||||
finish(new DaemonProtocolError('Invalid daemon identity'))
|
||||
return
|
||||
}
|
||||
finish(undefined, identity)
|
||||
finish(undefined, {
|
||||
identity,
|
||||
capabilities: parseDaemonHelloCapabilities(response.capabilities),
|
||||
tail
|
||||
})
|
||||
} else {
|
||||
finish(
|
||||
new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected'))
|
||||
@@ -415,7 +479,24 @@ export class DaemonClient {
|
||||
})
|
||||
}
|
||||
|
||||
private setupControlParser(socket: Socket): () => void {
|
||||
private requestedHelloCapabilities(): DaemonHelloCapabilities {
|
||||
try {
|
||||
// Why nothing is offered without a proof keypair: authority admission is proof-only, so a
|
||||
// client that cannot prove itself must negotiate the legacy non-authority path instead.
|
||||
if (!this.terminalSessionAuthorityConsumerProofReady()) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
terminalSessionAuthority: CURRENT_DAEMON_HELLO_CAPABILITIES.terminalSessionAuthority,
|
||||
terminalAuthorityConsumerProof:
|
||||
CURRENT_DAEMON_HELLO_CAPABILITIES.terminalAuthorityConsumerProof
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
private setupControlParser(socket: Socket, initialData: Buffer): () => void {
|
||||
// Why: control responses may contain terminal/startup data with multibyte
|
||||
// text; keep incomplete UTF-8 bytes until the next socket chunk.
|
||||
const decoder = new StringDecoder('utf8')
|
||||
@@ -445,10 +526,13 @@ export class DaemonClient {
|
||||
|
||||
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
|
||||
socket.on('data', onData)
|
||||
if (initialData.length > 0) {
|
||||
parser.feed(decoder.write(initialData))
|
||||
}
|
||||
return () => socket.off('data', onData)
|
||||
}
|
||||
|
||||
private setupStreamParser(socket: Socket): () => void {
|
||||
private setupStreamParser(socket: Socket, initialData: Buffer): () => void {
|
||||
// Why: PTY output streams include emoji/box-drawing tables; socket chunks
|
||||
// can split those UTF-8 sequences across packets.
|
||||
const decoder = new StringDecoder('utf8')
|
||||
@@ -466,6 +550,9 @@ export class DaemonClient {
|
||||
|
||||
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
|
||||
socket.on('data', onData)
|
||||
if (initialData.length > 0) {
|
||||
parser.feed(decoder.write(initialData))
|
||||
}
|
||||
return () => socket.off('data', onData)
|
||||
}
|
||||
|
||||
@@ -480,6 +567,7 @@ export class DaemonClient {
|
||||
}
|
||||
this.connected = false
|
||||
this.daemonIdentity = null
|
||||
this.negotiatedCapabilities = {}
|
||||
this.cleanupActiveSocketListeners()
|
||||
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
|
||||
@@ -18,6 +18,18 @@ describe('BackgroundTransientFactRelay', () => {
|
||||
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }])
|
||||
})
|
||||
|
||||
it('routes one scan through its captured exact emitter', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
const exact: DaemonTransientFact[] = []
|
||||
relay.setSessionBackground('s1', true)
|
||||
|
||||
relay.onSessionData('s1', 'first\x07', (fact) => exact.push(fact))
|
||||
relay.onSessionData('s1', 'second\x07')
|
||||
|
||||
expect(exact).toEqual([{ kind: 'bell' }])
|
||||
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }])
|
||||
})
|
||||
|
||||
it('keeps OSC escape state across chunks — a title terminator BEL is not a bell', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
|
||||
@@ -32,6 +32,7 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND
|
||||
|
||||
export class BackgroundTransientFactRelay {
|
||||
private trackersBySessionId = new Map<string, TerminalTitleTracker>()
|
||||
private factEmittersBySessionId = new Map<string, (fact: DaemonTransientFact) => void>()
|
||||
// Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff.
|
||||
private mode2031ReplyScanStateBySessionId = new Map<string, Mode2031ReplyScanState>()
|
||||
private emitFact: (sessionId: string, fact: DaemonTransientFact) => void
|
||||
@@ -59,15 +60,15 @@ export class BackgroundTransientFactRelay {
|
||||
this.trackersBySessionId.set(
|
||||
sessionId,
|
||||
createTerminalTitleTracker({
|
||||
onBell: () => this.emitFact(sessionId, { kind: 'bell' }),
|
||||
onBell: () => this.emitSessionFact(sessionId, { kind: 'bell' }),
|
||||
onCommandFinished: (exitCode) =>
|
||||
this.emitFact(sessionId, { kind: 'command-finished', exitCode }),
|
||||
this.emitSessionFact(sessionId, { kind: 'command-finished', exitCode }),
|
||||
// Note: recreating the tracker on each background toggle resets the
|
||||
// PR-link dedup memory, so a link re-printed across toggles can
|
||||
// re-fire — consumers treat pr-link as a latest-association update.
|
||||
onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }),
|
||||
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }),
|
||||
onMode2031Unsubscribe: () => this.emitFact(sessionId, { kind: '2031-unsubscribe' })
|
||||
onPrLink: (link) => this.emitSessionFact(sessionId, { kind: 'pr-link', link }),
|
||||
onMode2031Subscribe: () => this.emitSessionFact(sessionId, { kind: '2031-subscribe' }),
|
||||
onMode2031Unsubscribe: () => this.emitSessionFact(sessionId, { kind: '2031-unsubscribe' })
|
||||
})
|
||||
)
|
||||
} else {
|
||||
@@ -103,23 +104,42 @@ export class BackgroundTransientFactRelay {
|
||||
|
||||
/** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery —
|
||||
* facts must be captured even when the chunk is later keep-tail dropped. */
|
||||
onSessionData(sessionId: string, data: string): void {
|
||||
const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId)
|
||||
if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) {
|
||||
const mode2031Result = scanMode2031ReplyDecision(
|
||||
previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
data
|
||||
)
|
||||
if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) {
|
||||
this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state)
|
||||
} else {
|
||||
this.mode2031ReplyScanStateBySessionId.delete(sessionId)
|
||||
onSessionData(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
emitFact?: (fact: DaemonTransientFact) => void
|
||||
): void {
|
||||
const tracker = this.trackersBySessionId.get(sessionId)
|
||||
const installsEmitter = Boolean(emitFact && tracker)
|
||||
const previousEmitter = installsEmitter
|
||||
? this.factEmittersBySessionId.get(sessionId)
|
||||
: undefined
|
||||
if (installsEmitter && emitFact) {
|
||||
this.factEmittersBySessionId.set(sessionId, emitFact)
|
||||
}
|
||||
try {
|
||||
const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId)
|
||||
if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) {
|
||||
const mode2031Result = scanMode2031ReplyDecision(
|
||||
previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
data
|
||||
)
|
||||
if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) {
|
||||
this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state)
|
||||
} else {
|
||||
this.mode2031ReplyScanStateBySessionId.delete(sessionId)
|
||||
}
|
||||
}
|
||||
tracker?.handleChunk(data, { titleScanData: '' })
|
||||
} finally {
|
||||
if (installsEmitter) {
|
||||
if (previousEmitter) {
|
||||
this.factEmittersBySessionId.set(sessionId, previousEmitter)
|
||||
} else {
|
||||
this.factEmittersBySessionId.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// titleScanData:'' skips title extraction (titles stay main-authoritative)
|
||||
// and keeps the stale-working-title timer permanently unarmed — only the
|
||||
// four transient scanners consume the chunk.
|
||||
this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' })
|
||||
}
|
||||
|
||||
getMode2031ReplyScanState(sessionId: string): Mode2031ReplyScanState {
|
||||
@@ -143,5 +163,15 @@ export class BackgroundTransientFactRelay {
|
||||
private disposeTracker(sessionId: string): void {
|
||||
this.trackersBySessionId.get(sessionId)?.dispose()
|
||||
this.trackersBySessionId.delete(sessionId)
|
||||
this.factEmittersBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
private emitSessionFact(sessionId: string, fact: DaemonTransientFact): void {
|
||||
const emitter = this.factEmittersBySessionId.get(sessionId)
|
||||
if (emitter) {
|
||||
emitter(fact)
|
||||
return
|
||||
}
|
||||
this.emitFact(sessionId, fact)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TuiAgent } from '../../shared/types'
|
||||
import type { ShellReadyState, TerminalSnapshot } from './types'
|
||||
import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
export type DaemonCreateOrAttachResult = {
|
||||
isNew: boolean
|
||||
@@ -14,6 +15,8 @@ export type DaemonCreateOrAttachResult = {
|
||||
wslDistro?: string | null
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
incarnationId?: PtyIncarnationId
|
||||
streamBindingNonce?: string
|
||||
terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess
|
||||
}
|
||||
|
||||
export function getDaemonSessionResultMetadata(session: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Shuts down cleanly on SIGTERM.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
@@ -21,6 +22,8 @@ import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
import { publishDaemonPidFile } from './daemon-spawner'
|
||||
import { TerminalSessionAuthorityHostRuntime } from '../session-authority/terminal-session-authority-host-runtime'
|
||||
import { readCurrentTerminalAuthorityOwnerProcessIdentity } from '../session-authority/terminal-session-authority-owner-process'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
@@ -170,7 +173,7 @@ async function main(): Promise<void> {
|
||||
// forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates).
|
||||
const SHUTDOWN_TIMEOUT_MS = 5000
|
||||
|
||||
const shutdown = async (reason: string): Promise<void> => {
|
||||
const shutdown = async (reason: string, exitCode = 0): Promise<void> => {
|
||||
// SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown().
|
||||
if (shuttingDown) {
|
||||
return
|
||||
@@ -193,7 +196,7 @@ async function main(): Promise<void> {
|
||||
daemonLog.log('shutdown-error', { message: (err as Error)?.message })
|
||||
} finally {
|
||||
daemonLog.close()
|
||||
process.exit(0)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +261,10 @@ async function main(): Promise<void> {
|
||||
})
|
||||
: null
|
||||
|
||||
const terminalSessionAuthorityRuntime = await TerminalSessionAuthorityHostRuntime.open({
|
||||
directory: path.join(path.dirname(tokenPath), 'terminal-session-authority'),
|
||||
processIdentity: await readCurrentTerminalAuthorityOwnerProcessIdentity()
|
||||
})
|
||||
daemon = await startDaemon({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
@@ -281,7 +288,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
: {}),
|
||||
log: daemonLog,
|
||||
terminalSessionAuthorityRuntime,
|
||||
preparePtySpawn: runMacosLoginPreflight,
|
||||
onTerminalSessionAuthorityFailure: (error) => {
|
||||
daemonLog.log('terminal-session-authority-failure', { message: error.message })
|
||||
void shutdown('terminal-session-authority-failure', 1)
|
||||
},
|
||||
...(deathWatch
|
||||
? {
|
||||
onPtySessionExit: () => deathWatch.notifyPtyExit(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
|
||||
describe('foreground-confirmation daemon protocol', () => {
|
||||
it('rejects daemons from before the fresh-confirmation RPC', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(32)
|
||||
expect(PROTOCOL_VERSION).toBe(35)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
|
||||
@@ -15,5 +15,7 @@ describe('foreground-confirmation daemon protocol', () => {
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof'
|
||||
import { TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } from '../../shared/terminal-session-authority-consumer-retirement'
|
||||
import { assertAuthorityId } from '../../shared/terminal-session-authority-identity'
|
||||
|
||||
export const TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY = 1 as const
|
||||
|
||||
export type DaemonTerminalAuthorityConsumerProofOffer = Readonly<{
|
||||
versions: readonly number[]
|
||||
retirementVersions?: readonly number[]
|
||||
}>
|
||||
|
||||
export type DaemonTerminalAuthorityConsumerProofGrant = Readonly<{
|
||||
version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION
|
||||
authorityHostId: string
|
||||
retirementVersion?: typeof TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION
|
||||
}>
|
||||
|
||||
export type DaemonHelloCapabilities = {
|
||||
terminalSessionAuthority?: typeof TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY
|
||||
/** Proof-only: a peer that offers none negotiates no authority and stays on the legacy path. */
|
||||
terminalAuthorityConsumerProof?:
|
||||
| DaemonTerminalAuthorityConsumerProofOffer
|
||||
| DaemonTerminalAuthorityConsumerProofGrant
|
||||
}
|
||||
|
||||
export const CURRENT_DAEMON_HELLO_CAPABILITIES: Readonly<DaemonHelloCapabilities> = Object.freeze({
|
||||
terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY,
|
||||
terminalAuthorityConsumerProof: Object.freeze({
|
||||
versions: Object.freeze([TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION]),
|
||||
retirementVersions: Object.freeze([TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION])
|
||||
})
|
||||
})
|
||||
|
||||
export type HelloMessage = {
|
||||
type: 'hello'
|
||||
version: number
|
||||
token: string
|
||||
clientId: string
|
||||
role: 'control' | 'stream'
|
||||
capabilities?: DaemonHelloCapabilities
|
||||
}
|
||||
|
||||
export type DaemonEndpointIdentity = {
|
||||
@@ -21,4 +55,122 @@ export type HelloResponse = {
|
||||
ok: boolean
|
||||
error?: string
|
||||
daemonIdentity?: DaemonEndpointIdentity
|
||||
capabilities?: DaemonHelloCapabilities
|
||||
}
|
||||
|
||||
export function parseDaemonHelloCapabilities(value: unknown): DaemonHelloCapabilities {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
// Unknown members are dropped, not rejected: a newer peer may offer capabilities this build has no name for.
|
||||
const raw = value as {
|
||||
terminalSessionAuthority?: unknown
|
||||
terminalAuthorityConsumerProof?: unknown
|
||||
}
|
||||
const consumerProof = parseConsumerProofCapability(raw.terminalAuthorityConsumerProof)
|
||||
return {
|
||||
...(raw.terminalSessionAuthority === TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY
|
||||
? { terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY }
|
||||
: {}),
|
||||
...(consumerProof ? { terminalAuthorityConsumerProof: consumerProof } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function sameDaemonHelloCapabilities(
|
||||
left: DaemonHelloCapabilities,
|
||||
right: DaemonHelloCapabilities
|
||||
): boolean {
|
||||
return (
|
||||
left.terminalSessionAuthority === right.terminalSessionAuthority &&
|
||||
sameConsumerProofCapability(
|
||||
left.terminalAuthorityConsumerProof,
|
||||
right.terminalAuthorityConsumerProof
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parseConsumerProofCapability(
|
||||
value: unknown
|
||||
): DaemonTerminalAuthorityConsumerProofOffer | DaemonTerminalAuthorityConsumerProofGrant | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const raw = value as {
|
||||
versions?: unknown
|
||||
retirementVersions?: unknown
|
||||
version?: unknown
|
||||
retirementVersion?: unknown
|
||||
authorityHostId?: unknown
|
||||
}
|
||||
if (
|
||||
Array.isArray(raw.versions) &&
|
||||
raw.versions.length <= 8 &&
|
||||
raw.versions.every((version) => Number.isSafeInteger(version) && Number(version) > 0)
|
||||
) {
|
||||
const offeredRetirementVersions = Array.isArray(raw.retirementVersions)
|
||||
? raw.retirementVersions
|
||||
: null
|
||||
const retirementVersions = offeredRetirementVersions
|
||||
? offeredRetirementVersions.filter(
|
||||
(version) => Number.isSafeInteger(version) && Number(version) > 0
|
||||
)
|
||||
: []
|
||||
return Object.freeze({
|
||||
versions: Object.freeze(raw.versions.map(Number)),
|
||||
...(retirementVersions.length === offeredRetirementVersions?.length &&
|
||||
retirementVersions.length > 0
|
||||
? { retirementVersions: Object.freeze(retirementVersions.map(Number)) }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
if (raw.version !== TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
assertAuthorityId(raw.authorityHostId, 'authorityHostId')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
authorityHostId: raw.authorityHostId,
|
||||
...(raw.retirementVersion === TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION
|
||||
? { retirementVersion: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
function sameConsumerProofCapability(
|
||||
left: DaemonHelloCapabilities['terminalAuthorityConsumerProof'],
|
||||
right: DaemonHelloCapabilities['terminalAuthorityConsumerProof']
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right
|
||||
}
|
||||
if ('versions' in left || 'versions' in right) {
|
||||
return (
|
||||
'versions' in left &&
|
||||
'versions' in right &&
|
||||
left.versions.length === right.versions.length &&
|
||||
left.versions.every((version, index) => version === right.versions[index]) &&
|
||||
sameOptionalVersions(left.retirementVersions, right.retirementVersions)
|
||||
)
|
||||
}
|
||||
return (
|
||||
left.version === right.version &&
|
||||
left.authorityHostId === right.authorityHostId &&
|
||||
left.retirementVersion === right.retirementVersion
|
||||
)
|
||||
}
|
||||
|
||||
function sameOptionalVersions(left?: readonly number[], right?: readonly number[]): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
Boolean(
|
||||
left &&
|
||||
right &&
|
||||
left.length === right.length &&
|
||||
left.every((version, index) => version === right[index])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,6 +248,8 @@ type MockAdapter = {
|
||||
shutdown: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
disconnectOnly: ReturnType<typeof vi.fn>
|
||||
terminalAuthorityAppHostTransport: ReturnType<typeof vi.fn>
|
||||
installTerminalAuthorityAppAdmission: ReturnType<typeof vi.fn>
|
||||
onData: ReturnType<typeof vi.fn>
|
||||
onExit: ReturnType<typeof vi.fn>
|
||||
// Why: the router calls onData/onExit on each adapter; the stub returns a no-op unsubscribe so router subscription doesn't explode.
|
||||
@@ -295,6 +297,13 @@ vi.mock('./daemon-health', () => ({
|
||||
|
||||
vi.mock('./client', () => ({ DaemonClient: daemonClientMock }))
|
||||
|
||||
vi.mock('../session-authority/terminal-authority-consumer-proof-keypair', () => ({
|
||||
getTerminalAuthorityConsumerProofKeypair: () => ({
|
||||
publicKey: new Uint8Array(32).fill(1),
|
||||
secretKey: new Uint8Array(32).fill(2)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./daemon-lifecycle-event', () => ({
|
||||
trackDaemonReplaced: trackDaemonReplacedMock,
|
||||
trackDaemonRetired: trackDaemonRetiredMock
|
||||
@@ -375,6 +384,8 @@ vi.mock('./daemon-pty-adapter', () => ({
|
||||
readonly shutdown: ReturnType<typeof vi.fn>
|
||||
readonly dispose: ReturnType<typeof vi.fn>
|
||||
readonly disconnectOnly: ReturnType<typeof vi.fn>
|
||||
readonly terminalAuthorityAppHostTransport: ReturnType<typeof vi.fn>
|
||||
readonly installTerminalAuthorityAppAdmission: ReturnType<typeof vi.fn>
|
||||
readonly onData: ReturnType<typeof vi.fn>
|
||||
readonly onExit: ReturnType<typeof vi.fn>
|
||||
readonly callOrder: string[]
|
||||
@@ -404,6 +415,8 @@ vi.mock('./daemon-pty-adapter', () => ({
|
||||
throw disconnectOnlyError
|
||||
}
|
||||
})
|
||||
this.terminalAuthorityAppHostTransport = vi.fn(async () => null)
|
||||
this.installTerminalAuthorityAppAdmission = vi.fn()
|
||||
this.onData = vi.fn(() => {
|
||||
if (routerSubscriptionError.current) {
|
||||
const error = routerSubscriptionError.current
|
||||
|
||||
@@ -59,6 +59,8 @@ import {
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import { getTerminalAuthorityConsumerProofKeypair } from '../session-authority/terminal-authority-consumer-proof-keypair'
|
||||
import { installPtyAuthorityOutcomeHostTransport } from '../ipc/pty-authority-outcome-ipc'
|
||||
|
||||
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
|
||||
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
@@ -907,6 +909,7 @@ export async function initDaemonPtyProvider(
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir,
|
||||
historyPath: getHistoryDir(),
|
||||
terminalSessionAuthorityConsumerProofKeypair: getTerminalAuthorityConsumerProofKeypair(),
|
||||
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
|
||||
respawn: async (reason: DaemonRespawnReason) => {
|
||||
// Why: attribute rather than emit — the launcher below is the one that completes the
|
||||
@@ -935,6 +938,7 @@ export async function initDaemonPtyProvider(
|
||||
// Why: the launcher's temporary pair closes only after this permanent pair is established, leaving no adoption gap.
|
||||
await newAdapter.establishLifecycleLease()
|
||||
releaseDaemonAdoptionLease(newSpawner.getHandle())
|
||||
await installDaemonAuthorityAppAdmission(newAdapter)
|
||||
|
||||
legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
|
||||
routedAdapter =
|
||||
@@ -1058,6 +1062,33 @@ function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdapter {
|
||||
return provider
|
||||
}
|
||||
|
||||
async function installDaemonAuthorityAppAdmission(daemonAdapter: DaemonPtyAdapter): Promise<void> {
|
||||
const transport = await daemonAdapter.terminalAuthorityAppHostTransport()
|
||||
if (transport) {
|
||||
daemonAdapter.installTerminalAuthorityAppAdmission(
|
||||
installPtyAuthorityOutcomeHostTransport(transport)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebinds the live daemon consumer to the successor proof key after identity reset publication. */
|
||||
export async function refreshDaemonAuthorityAppProofIdentity(): Promise<void> {
|
||||
if (!adapter) {
|
||||
return
|
||||
}
|
||||
const adapters =
|
||||
adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider
|
||||
? adapter.getAllAdapters()
|
||||
: [adapter]
|
||||
const proofKeypair = getTerminalAuthorityConsumerProofKeypair()
|
||||
for (const daemonAdapter of adapters) {
|
||||
if (daemonAdapter === getCurrentDaemonAdapter(adapter)) {
|
||||
daemonAdapter.setTerminalAuthorityConsumerProofKeypair(proofKeypair)
|
||||
await installDaemonAuthorityAppAdmission(daemonAdapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] {
|
||||
if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) {
|
||||
return [...provider.getLegacyAdapters()]
|
||||
@@ -1142,6 +1173,7 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir,
|
||||
historyPath: getHistoryDir(),
|
||||
terminalSessionAuthorityConsumerProofKeypair: getTerminalAuthorityConsumerProofKeypair(),
|
||||
respawn: async (reason: DaemonRespawnReason) => {
|
||||
// Why: attribute rather than emit — the launcher below is the one that completes the
|
||||
// replacement, and emitting here would fire before the outcome is known.
|
||||
@@ -1168,6 +1200,7 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
|
||||
// Temporary launcher lease overlaps this permanent pair so a manual restart can't strand a newly spawned daemon during adoption.
|
||||
await newCurrent.establishLifecycleLease()
|
||||
releaseDaemonAdoptionLease(currentSpawner.getHandle())
|
||||
await installDaemonAuthorityAppAdmission(newCurrent)
|
||||
|
||||
// Re-wrap in a router only if legacy adapters exist; they're preserved by reference and still route to their pre-upgrade daemons.
|
||||
newProvider =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DaemonServer, type DaemonServerOptions } from './daemon-server'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { TerminalSessionAuthorityHostRuntime } from '../session-authority/terminal-session-authority-host-runtime'
|
||||
|
||||
export type DaemonStartOptions = {
|
||||
socketPath: string
|
||||
@@ -20,7 +21,9 @@ export type DaemonStartOptions = {
|
||||
log?: DaemonFileLog
|
||||
onIdleShutdown?: () => void
|
||||
onRpcShutdown?: () => void
|
||||
onTerminalSessionAuthorityFailure?: (error: Error) => void
|
||||
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
|
||||
terminalSessionAuthorityRuntime?: TerminalSessionAuthorityHostRuntime
|
||||
}
|
||||
|
||||
export type DaemonHandle = {
|
||||
@@ -50,14 +53,32 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
|
||||
...(opts.log ? { log: opts.log } : {}),
|
||||
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
|
||||
...(opts.onRpcShutdown ? { onRpcShutdown: opts.onRpcShutdown } : {}),
|
||||
...(opts.onTerminalSessionAuthorityFailure
|
||||
? { onTerminalSessionAuthorityFailure: opts.onTerminalSessionAuthorityFailure }
|
||||
: {}),
|
||||
...(opts.initialAdoptionTestConfig
|
||||
? { initialAdoptionTestConfig: opts.initialAdoptionTestConfig }
|
||||
: {}),
|
||||
...(opts.terminalSessionAuthorityRuntime
|
||||
? {
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: opts.terminalSessionAuthorityRuntime.ptyOwner,
|
||||
authorityHostId: opts.terminalSessionAuthorityRuntime.authorityHostId
|
||||
},
|
||||
terminalSessionAuthorityCapabilityReadiness: {
|
||||
hostEffectConsumerInstalled: () =>
|
||||
opts.terminalSessionAuthorityRuntime!.ptyOwner.hostEffectConsumerInstalled()
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
return {
|
||||
shutdown: () => server.shutdown()
|
||||
shutdown: async () => {
|
||||
await server.shutdown()
|
||||
await opts.terminalSessionAuthorityRuntime?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,31 @@ import {
|
||||
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
|
||||
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION,
|
||||
EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
|
||||
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
|
||||
SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION,
|
||||
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION,
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
||||
PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact
|
||||
PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION,
|
||||
supportsExactPtyOperations,
|
||||
supportsExactHeldProducerPause,
|
||||
supportsMode2031UnsubscribeFact,
|
||||
supportsPtyStreamBinding,
|
||||
supportsTerminalSessionAuthority
|
||||
} from './daemon-protocol-version'
|
||||
|
||||
describe('daemon protocol version', () => {
|
||||
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(32)
|
||||
expect(PROTOCOL_VERSION).toBe(35)
|
||||
expect(EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION).toBe(35)
|
||||
expect(TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION).toBe(34)
|
||||
expect(EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION).toBe(33)
|
||||
expect(PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION).toBe(33)
|
||||
expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32)
|
||||
expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31)
|
||||
expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30)
|
||||
@@ -25,10 +37,28 @@ describe('daemon protocol version', () => {
|
||||
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
|
||||
Array.from({ length: 31 }, (_, index) => index + 1)
|
||||
Array.from({ length: 34 }, (_, index) => index + 1)
|
||||
)
|
||||
})
|
||||
|
||||
it('admits exact held pauses only at the acknowledged v35 boundary', () => {
|
||||
expect(supportsExactHeldProducerPause(34)).toBe(false)
|
||||
expect(supportsExactHeldProducerPause(35)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps v33 incarnation fencing explicit but authority-unavailable', () => {
|
||||
expect(supportsExactPtyOperations(33)).toBe(true)
|
||||
expect(supportsTerminalSessionAuthority(33)).toBe(false)
|
||||
expect(supportsTerminalSessionAuthority(34)).toBe(true)
|
||||
})
|
||||
|
||||
it('advertises the complete exact contract at one coherent v33 boundary', () => {
|
||||
expect(supportsExactPtyOperations(32)).toBe(false)
|
||||
expect(supportsExactPtyOperations(33)).toBe(true)
|
||||
expect(supportsPtyStreamBinding(32)).toBe(false)
|
||||
expect(supportsPtyStreamBinding(33)).toBe(true)
|
||||
})
|
||||
|
||||
it('withholds 2031-unsubscribe support only before its v29 boundary', () => {
|
||||
// Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app
|
||||
// update is the live hazard — it emits '2031-subscribe' with no way to retract it.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Why: daemons survive app updates, so wire behavior must be version-gated.
|
||||
// v32 carries the corrected terminal snapshot serializer; older owners remain attachable.
|
||||
export const PROTOCOL_VERSION = 32
|
||||
// v35 adds acknowledged, incarnation-fenced producer-pause leases.
|
||||
export const PROTOCOL_VERSION = 35
|
||||
export const EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION = 35
|
||||
export const TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION = 34
|
||||
export const EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION = 33
|
||||
export const PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION = 33
|
||||
export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32
|
||||
export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31
|
||||
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
|
||||
@@ -26,9 +30,25 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
|
||||
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
|
||||
28, 29, 30, 31
|
||||
28, 29, 30, 31, 32, 33, 34
|
||||
] as const
|
||||
|
||||
export function supportsExactHeldProducerPause(protocolVersion: number): boolean {
|
||||
return protocolVersion >= EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
export function supportsTerminalSessionAuthority(protocolVersion: number): boolean {
|
||||
return protocolVersion >= TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
export function supportsExactPtyOperations(protocolVersion: number): boolean {
|
||||
return protocolVersion >= EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
export function supportsPtyStreamBinding(protocolVersion: number): boolean {
|
||||
return protocolVersion >= PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
|
||||
return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
import type { TerminalAuthorityAppNamespaceAdmission } from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
|
||||
const adapters: DaemonPtyAdapter[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const adapter of adapters.splice(0)) {
|
||||
adapter.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('DaemonPtyAdapter authority app admission', () => {
|
||||
it('resolves and admits the namespace before authoritative create-or-attach', async () => {
|
||||
const order: string[] = []
|
||||
const namespace = Object.freeze({
|
||||
authorityHostId: 'authority-host:daemon-preflight',
|
||||
namespaceId: 'namespace:daemon-preflight'
|
||||
})
|
||||
const adapter = adapterWithCreateResult(order, namespace)
|
||||
const admission: TerminalAuthorityAppNamespaceAdmission & Readonly<{ dispose(): void }> = {
|
||||
admitNamespace: vi.fn(async () => {
|
||||
order.push('admit')
|
||||
}),
|
||||
resolveAndAdmitNamespace: vi.fn(async () => {
|
||||
order.push('resolve-and-admit')
|
||||
return namespace
|
||||
}),
|
||||
withSourceAdmission: vi.fn(async (locator, operation) => {
|
||||
expect(locator).toEqual({ worktreeId: 'repo::workspace' })
|
||||
order.push('resolve-and-admit')
|
||||
const result = await operation({ namespace, assertCurrent: vi.fn() })
|
||||
order.push('release')
|
||||
return result
|
||||
}),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
adapter.installTerminalAuthorityAppAdmission(admission)
|
||||
|
||||
await adapter.spawn({
|
||||
sessionId: 'daemon-preflight-session',
|
||||
worktreeId: 'repo::workspace',
|
||||
paneKey: 'pane-preflight',
|
||||
paneGeneration: 4,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(order).toEqual(['resolve-and-admit', 'create-or-attach', 'release'])
|
||||
expect(admission.withSourceAdmission).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails before create-or-attach when the app consumer is unavailable', async () => {
|
||||
const order: string[] = []
|
||||
const adapter = adapterWithCreateResult(
|
||||
order,
|
||||
Object.freeze({
|
||||
authorityHostId: 'authority-host:daemon-preflight',
|
||||
namespaceId: 'namespace:daemon-preflight'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
adapter.spawn({
|
||||
sessionId: 'daemon-preflight-missing',
|
||||
worktreeId: 'repo::workspace',
|
||||
paneKey: 'pane-preflight',
|
||||
paneGeneration: 4,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
).rejects.toThrow('app_consumer_unavailable')
|
||||
expect(order).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function adapterWithCreateResult(
|
||||
order: string[],
|
||||
namespace: Readonly<{ authorityHostId: string; namespaceId: string }>
|
||||
): DaemonPtyAdapter {
|
||||
vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue()
|
||||
vi.spyOn(DaemonClient.prototype, 'supportsTerminalSessionAuthority').mockReturnValue(true)
|
||||
vi.spyOn(DaemonClient.prototype, 'request').mockImplementation(async (type, payload) => {
|
||||
if (type !== 'createOrAttach') {
|
||||
return {} as never
|
||||
}
|
||||
order.push('create-or-attach')
|
||||
const request = payload as Record<string, unknown>
|
||||
return {
|
||||
isNew: true,
|
||||
incarnationId: 'pty-incarnation:daemon-preflight',
|
||||
pid: 42,
|
||||
shellState: 'unsupported',
|
||||
snapshot: null,
|
||||
streamBindingNonce: request.streamBindingNonce,
|
||||
terminalSessionAuthorityAccess: {
|
||||
namespace,
|
||||
pane: {
|
||||
paneKey: 'pane-preflight',
|
||||
paneGenerationId: 'renderer:4'
|
||||
},
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-incarnation:daemon-preflight',
|
||||
physicalPtyId: request.sessionId,
|
||||
ptyIncarnationId: 'pty-incarnation:daemon-preflight'
|
||||
}
|
||||
}
|
||||
} as never
|
||||
})
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: join(tmpdir(), 'orca-daemon-preflight.socket'),
|
||||
tokenPath: join(tmpdir(), 'orca-daemon-preflight.token'),
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
})
|
||||
adapters.push(adapter)
|
||||
return adapter
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import {
|
||||
EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION,
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION
|
||||
} from './daemon-protocol-version'
|
||||
import type { SessionInfo } from './types'
|
||||
import { killListedPty } from '../providers/pty-listed-session-kill'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
const adapters: DaemonPtyAdapter[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const adapter of adapters.splice(0)) {
|
||||
adapter.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('DaemonPtyAdapter authority inventory', () => {
|
||||
it('fails closed against v32 inventory without exact operations', async () => {
|
||||
const { adapter, request } = adapterWithInventory(
|
||||
EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION - 1,
|
||||
[listedSession('session-v32', 'incarnation-v32')]
|
||||
)
|
||||
|
||||
const [listed] = await adapter.listProcesses()
|
||||
|
||||
expect(listed).toBeDefined()
|
||||
await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(false)
|
||||
expect(request.mock.calls.some(([type]) => type === 'killExact')).toBe(false)
|
||||
expect(request.mock.calls.some(([type]) => type === 'killAuthorityExact')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the listed incarnation for v33 orphan-safe shutdown', async () => {
|
||||
const session = listedSession('session-v33', 'incarnation-v33')
|
||||
const { adapter, request } = adapterWithInventory(
|
||||
EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION,
|
||||
[session]
|
||||
)
|
||||
|
||||
const [listed] = await adapter.listProcesses()
|
||||
|
||||
await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(true)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'killExact',
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
incarnationId: session.incarnationId,
|
||||
immediate: true
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(request.mock.calls.some(([type]) => type === 'kill')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses full authority access for v34 inventory shutdown', async () => {
|
||||
const access = authorityAccess('session-v34', 'incarnation-v34')
|
||||
const session = listedSession('session-v34', 'incarnation-v34', access)
|
||||
const { adapter, request } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
[session]
|
||||
)
|
||||
|
||||
const [listed] = await adapter.listProcesses()
|
||||
|
||||
expect(listed?.terminalSessionAuthorityAccess).toEqual(access)
|
||||
await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(true)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'killAuthorityExact',
|
||||
{ sessionId: session.sessionId, authorityAccess: access, immediate: true },
|
||||
undefined
|
||||
)
|
||||
expect(request.mock.calls.some(([type]) => type === 'killExact')).toBe(false)
|
||||
expect(request.mock.calls.some(([type]) => type === 'kill')).toBe(false)
|
||||
})
|
||||
|
||||
it('upgrades every incarnation-only mutation to the cached authority access', async () => {
|
||||
const access = authorityAccess('authority-session', 'authority-incarnation')
|
||||
const { adapter, notify, request } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
[listedSession('authority-session', 'authority-incarnation', access)]
|
||||
)
|
||||
await adapter.listProcesses()
|
||||
|
||||
expect(adapter.bindTerminalSessionAuthorityAccess('authority-session', access)).toBe(true)
|
||||
expect(
|
||||
adapter.bindTerminalSessionAuthorityAccess(
|
||||
'authority-session',
|
||||
authorityAccess('authority-session', 'stale-incarnation')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(adapter.writeExact('authority-session', 'stale-incarnation', 'stale')).toBe(false)
|
||||
expect(adapter.resizeExact('authority-session', 'stale-incarnation', 90, 30)).toBe(false)
|
||||
await expect(
|
||||
adapter.sendSignalExact('authority-session', 'stale-incarnation', 'SIGTERM')
|
||||
).resolves.toBe(false)
|
||||
await expect(adapter.clearBufferExact('authority-session', 'stale-incarnation')).resolves.toBe(
|
||||
false
|
||||
)
|
||||
await expect(
|
||||
adapter.killExact('authority-session', 'stale-incarnation', { immediate: true })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(adapter.writeExact('authority-session', 'authority-incarnation', 'current')).toBe(true)
|
||||
expect(adapter.resizeExact('authority-session', 'authority-incarnation', 120, 40)).toBe(true)
|
||||
await expect(
|
||||
adapter.sendSignalExact('authority-session', 'authority-incarnation', 'SIGTERM')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
adapter.clearBufferExact('authority-session', 'authority-incarnation')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
adapter.killExact('authority-session', 'authority-incarnation', { immediate: true })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(notify).toHaveBeenCalledWith('writeAuthorityExact', {
|
||||
sessionId: 'authority-session',
|
||||
authorityAccess: access,
|
||||
data: 'current'
|
||||
})
|
||||
expect(notify).toHaveBeenCalledWith('resizeAuthorityExact', {
|
||||
sessionId: 'authority-session',
|
||||
authorityAccess: access,
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
expect(request.mock.calls.map(([type]) => type)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'signalAuthorityExact',
|
||||
'clearBufferAuthorityExact',
|
||||
'killAuthorityExact'
|
||||
])
|
||||
)
|
||||
expect(
|
||||
[...notify.mock.calls, ...request.mock.calls].some(
|
||||
([type]) =>
|
||||
type === 'writeExact' ||
|
||||
type === 'resizeExact' ||
|
||||
type === 'signalExact' ||
|
||||
type === 'clearBufferExact' ||
|
||||
type === 'killExact'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns full authority and route evidence to Manage Sessions inventory', async () => {
|
||||
const access = authorityAccess('managed-session', 'managed-incarnation')
|
||||
const { adapter } = adapterWithInventory(TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, [
|
||||
listedSession('managed-session', 'managed-incarnation', access)
|
||||
])
|
||||
|
||||
const [listed] = await adapter.listSessions()
|
||||
|
||||
expect(listed).toMatchObject({
|
||||
sessionId: 'managed-session',
|
||||
incarnationId: 'managed-incarnation',
|
||||
terminalSessionAuthorityAccess: access,
|
||||
mutationRouteToken: expect.any(Object)
|
||||
})
|
||||
expect(adapter.getPtyMutationRouteToken('managed-session')).toBe(listed?.mutationRouteToken)
|
||||
})
|
||||
|
||||
it('reconciles authority-owned startup orphans without a legacy kill', async () => {
|
||||
const access = authorityAccess('missing-worktree@@pty', 'orphan-incarnation')
|
||||
const { adapter, request } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
[listedSession('missing-worktree@@pty', 'orphan-incarnation', access)]
|
||||
)
|
||||
|
||||
await expect(adapter.reconcileOnStartup(new Set())).resolves.toEqual({
|
||||
alive: [],
|
||||
killed: ['missing-worktree@@pty']
|
||||
})
|
||||
expect(request).toHaveBeenCalledWith('killAuthorityExact', {
|
||||
sessionId: 'missing-worktree@@pty',
|
||||
authorityAccess: access
|
||||
})
|
||||
expect(request.mock.calls.some(([type]) => type === 'kill')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects authority access that does not bind the listed row', async () => {
|
||||
const mismatched = authorityAccess('different-session', 'incarnation-v34')
|
||||
const { adapter } = adapterWithInventory(TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, [
|
||||
listedSession('session-v34', 'incarnation-v34', mismatched)
|
||||
])
|
||||
|
||||
await expect(adapter.listProcesses()).rejects.toThrow(
|
||||
'daemon_terminal_session_authority_access_invalid'
|
||||
)
|
||||
expect(adapter.getPtyMutationRouteToken('session-v34')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an inventory downgrade for the same authority-owned incarnation', async () => {
|
||||
const access = authorityAccess('session-v34', 'incarnation-v34')
|
||||
let sessions = [listedSession('session-v34', 'incarnation-v34', access)]
|
||||
const { adapter } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
() => sessions
|
||||
)
|
||||
const [first] = await adapter.listProcesses()
|
||||
sessions = [listedSession('session-v34', 'incarnation-v34')]
|
||||
|
||||
await expect(adapter.listProcesses()).rejects.toThrow(
|
||||
'daemon_terminal_session_authority_access_missing'
|
||||
)
|
||||
expect(adapter.getPtyMutationRouteToken('session-v34')).toBe(first?.mutationRouteToken)
|
||||
})
|
||||
|
||||
it('invalidates old authority when inventory proves a replacement incarnation', async () => {
|
||||
const oldAccess = authorityAccess('reused-session', 'old-incarnation')
|
||||
let sessions = [listedSession('reused-session', 'old-incarnation', oldAccess)]
|
||||
const { adapter, request } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
() => sessions
|
||||
)
|
||||
const [oldListing] = await adapter.listProcesses()
|
||||
sessions = [listedSession('reused-session', 'new-incarnation')]
|
||||
const [replacement] = await adapter.listProcesses()
|
||||
|
||||
await expect(killListedPty(adapter, oldListing!, { immediate: true })).resolves.toBe(false)
|
||||
await expect(killListedPty(adapter, replacement!, { immediate: true })).resolves.toBe(true)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'killExact',
|
||||
{
|
||||
sessionId: 'reused-session',
|
||||
incarnationId: 'new-incarnation',
|
||||
immediate: true
|
||||
},
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('retains authority across disconnect but invalidates listed route tokens', async () => {
|
||||
const access = authorityAccess('reconnect-session', 'reconnect-incarnation')
|
||||
const { adapter, request, disconnect } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
[listedSession('reconnect-session', 'reconnect-incarnation', access)]
|
||||
)
|
||||
const [listed] = await adapter.listProcesses()
|
||||
|
||||
disconnect()
|
||||
|
||||
expect(adapter.getPtyMutationRouteToken('reconnect-session')).toBeNull()
|
||||
await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(false)
|
||||
await expect(
|
||||
adapter.killAuthorityExact('reconnect-session', access, { immediate: true })
|
||||
).resolves.toBe(true)
|
||||
expect(request.mock.calls.some(([type]) => type === 'killAuthorityExact')).toBe(true)
|
||||
})
|
||||
|
||||
it('forgets authority when daemon replacement fans out synthetic exits', async () => {
|
||||
const access = authorityAccess('replaced-session', 'replaced-incarnation')
|
||||
const { adapter, request } = adapterWithInventory(
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
[listedSession('replaced-session', 'replaced-incarnation', access)]
|
||||
)
|
||||
await adapter.listProcesses()
|
||||
|
||||
adapter.fanoutSyntheticExits(-1)
|
||||
|
||||
expect(adapter.bindTerminalSessionAuthorityAccess('replaced-session', access)).toBe(false)
|
||||
await expect(
|
||||
adapter.killAuthorityExact('replaced-session', access, { immediate: true })
|
||||
).resolves.toBe(false)
|
||||
expect(request.mock.calls.some(([type]) => type === 'killAuthorityExact')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function adapterWithInventory(
|
||||
protocolVersion: number,
|
||||
sessions: SessionInfo[] | (() => SessionInfo[])
|
||||
): {
|
||||
adapter: DaemonPtyAdapter
|
||||
request: ReturnType<typeof vi.spyOn>
|
||||
notify: ReturnType<typeof vi.spyOn>
|
||||
disconnect: () => void
|
||||
} {
|
||||
vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue()
|
||||
vi.spyOn(DaemonClient.prototype, 'supportsTerminalSessionAuthority').mockReturnValue(
|
||||
protocolVersion >= TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION
|
||||
)
|
||||
const request = vi
|
||||
.spyOn(DaemonClient.prototype, 'request')
|
||||
.mockImplementation(async (type: string) => {
|
||||
if (type === 'listSessions') {
|
||||
return { sessions: typeof sessions === 'function' ? sessions() : sessions } as never
|
||||
}
|
||||
if (
|
||||
type === 'killExact' ||
|
||||
type === 'killAuthorityExact' ||
|
||||
type === 'signalAuthorityExact' ||
|
||||
type === 'clearBufferAuthorityExact'
|
||||
) {
|
||||
return { accepted: true } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
const notify = vi.spyOn(DaemonClient.prototype, 'notify').mockReturnValue(true)
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: join(tmpdir(), `orca-daemon-authority-${protocolVersion}.socket`),
|
||||
tokenPath: join(tmpdir(), `orca-daemon-authority-${protocolVersion}.token`),
|
||||
protocolVersion
|
||||
})
|
||||
adapters.push(adapter)
|
||||
const client = adapter as unknown as {
|
||||
client: { disconnectedListeners: (() => void)[] }
|
||||
}
|
||||
return {
|
||||
adapter,
|
||||
request,
|
||||
notify,
|
||||
disconnect: () => {
|
||||
for (const listener of client.client.disconnectedListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listedSession(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
access?: TerminalSessionAuthorityPtyAccess
|
||||
): SessionInfo {
|
||||
return {
|
||||
sessionId,
|
||||
incarnationId,
|
||||
...(access ? { terminalSessionAuthorityAccess: access } : {}),
|
||||
state: 'running',
|
||||
shellState: 'unsupported',
|
||||
isAlive: true,
|
||||
pid: 42,
|
||||
cwd: tmpdir(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function authorityAccess(
|
||||
physicalPtyId: string,
|
||||
ptyIncarnationId: string
|
||||
): TerminalSessionAuthorityPtyAccess {
|
||||
return {
|
||||
namespace: { authorityHostId: 'authority-host', namespaceId: 'namespace' },
|
||||
pane: { paneKey: 'pane', paneGenerationId: 'renderer:1' },
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-incarnation',
|
||||
physicalPtyId,
|
||||
ptyIncarnationId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
|
||||
type AdapterInternals = {
|
||||
setupEventRouting: () => void
|
||||
streamBindings: {
|
||||
begin: (nonce: string) => void
|
||||
acceptResponse: (
|
||||
nonce: string,
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
echoedNonce: string
|
||||
) => boolean
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('DaemonPtyAdapter exact output identity', () => {
|
||||
it('tags data from the admitted source and keeps a same-id successor after a stale marker', () => {
|
||||
const eventListeners: ((event: unknown) => void)[] = []
|
||||
vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => {
|
||||
eventListeners.push(listener)
|
||||
return () => {}
|
||||
})
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: join(tmpdir(), 'orca-daemon-output-incarnation.socket'),
|
||||
tokenPath: join(tmpdir(), 'orca-daemon-output-incarnation.token'),
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
})
|
||||
const received: { id: string; data: string; incarnationId?: string }[] = []
|
||||
adapter.onData((payload) => received.push(payload))
|
||||
const internals = adapter as unknown as AdapterInternals
|
||||
internals.setupEventRouting()
|
||||
|
||||
const emit = (event: string, payload: unknown): void => {
|
||||
for (const listener of eventListeners) {
|
||||
listener({ type: 'event', event, sessionId: 'reused-session', payload })
|
||||
}
|
||||
}
|
||||
const bind = (incarnationId: string, nonce: string): void => {
|
||||
internals.streamBindings.begin(nonce)
|
||||
emit('sessionSource', { incarnationId, streamBindingNonce: nonce })
|
||||
expect(
|
||||
internals.streamBindings.acceptResponse(nonce, 'reused-session', incarnationId, nonce)
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
emit('data', { data: 'unbound' })
|
||||
bind('incarnation-a', 'nonce-a')
|
||||
emit('data', { data: 'first' })
|
||||
bind('incarnation-b', 'nonce-b')
|
||||
emit('data', { data: 'successor' })
|
||||
emit('sessionSource', { incarnationId: 'incarnation-a', streamBindingNonce: 'nonce-a' })
|
||||
emit('data', { data: 'after-stale-marker' })
|
||||
|
||||
expect(received).toEqual([
|
||||
{ id: 'reused-session', data: 'first', incarnationId: 'incarnation-a' },
|
||||
{ id: 'reused-session', data: 'successor', incarnationId: 'incarnation-b' },
|
||||
{ id: 'reused-session', data: 'after-stale-marker', incarnationId: 'incarnation-b' }
|
||||
])
|
||||
adapter.dispose()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { DaemonProtocolError } from './daemon-errors'
|
||||
import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter'
|
||||
import {
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
GET_SIZE_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION
|
||||
@@ -59,6 +60,7 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & {
|
||||
resize: vi.fn(),
|
||||
pause: vi.fn<() => void>(),
|
||||
resume: vi.fn<() => void>(),
|
||||
clear: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)),
|
||||
signal: vi.fn(),
|
||||
@@ -163,6 +165,77 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
|
||||
})
|
||||
|
||||
it('binds first-attach output and exact input to the returned incarnation', async () => {
|
||||
const onData = vi.fn()
|
||||
adapter.onData(onData)
|
||||
const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'exact-session' })
|
||||
|
||||
expect(adapter.supportsExactPtyOperations(result.id)).toBe(false)
|
||||
// A daemon that negotiated no authority keeps its panes on the legacy mutation path; only a
|
||||
// pane that holds authority access can be 'unavailable'.
|
||||
expect(adapter.getPtyMutationMode(result.id)).toBe('legacy')
|
||||
lastSubprocess._simulateData('current-output')
|
||||
await waitFor(() => onData.mock.calls.length === 1)
|
||||
expect(onData).toHaveBeenCalledWith({
|
||||
id: result.id,
|
||||
data: 'current-output',
|
||||
incarnationId: result.incarnationId
|
||||
})
|
||||
expect(adapter.writeExact(result.id, 'stale-incarnation', 'stale')).toBe(false)
|
||||
expect(adapter.resizeExact(result.id, 'stale-incarnation', 120, 40)).toBe(false)
|
||||
await expect(
|
||||
adapter.sendSignalExact(result.id, 'stale-incarnation', 'SIGTERM')
|
||||
).resolves.toBe(false)
|
||||
await expect(adapter.clearBufferExact(result.id, 'stale-incarnation')).resolves.toBe(false)
|
||||
expect(adapter.writeExact(result.id, result.incarnationId!, 'current')).toBe(true)
|
||||
expect(adapter.resizeExact(result.id, result.incarnationId!, 120, 40)).toBe(true)
|
||||
await expect(
|
||||
adapter.sendSignalExact(result.id, result.incarnationId!, 'SIGTERM')
|
||||
).resolves.toBe(true)
|
||||
await expect(adapter.clearBufferExact(result.id, result.incarnationId!)).resolves.toBe(true)
|
||||
await waitFor(() => lastSubprocess.write.mock.calls.some(([data]) => data === 'current'))
|
||||
expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(lastSubprocess.signal).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(lastSubprocess.clear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed against a v32 daemon without the exact contract', async () => {
|
||||
const ensureConnectedSpy = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
.mockResolvedValue()
|
||||
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
|
||||
isNew: true,
|
||||
incarnationId: 'legacy-incarnation',
|
||||
pid: null,
|
||||
shellState: 'unsupported',
|
||||
snapshot: null
|
||||
} as never)
|
||||
const legacy = new DaemonPtyAdapter({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion: EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION - 1
|
||||
})
|
||||
try {
|
||||
const result = await legacy.spawn({
|
||||
sessionId: 'legacy-exact-session',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
|
||||
expect(createPayload).not.toHaveProperty('streamBindingNonce')
|
||||
expect(legacy.supportsExactPtyOperations(result.id)).toBe(false)
|
||||
expect(legacy.writeExact(result.id, 'legacy-incarnation', 'blocked')).toBe(false)
|
||||
await expect(
|
||||
legacy.sendSignalExact(result.id, 'legacy-incarnation', 'SIGTERM')
|
||||
).resolves.toBe(false)
|
||||
await expect(legacy.clearBufferExact(result.id, 'legacy-incarnation')).resolves.toBe(false)
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
requestSpy.mockRestore()
|
||||
ensureConnectedSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('carries classified startup spans from the daemon source to the adapter', async () => {
|
||||
const onData = vi.fn()
|
||||
adapter.onData(onData)
|
||||
@@ -184,11 +257,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(onData).toHaveBeenCalledWith({
|
||||
id,
|
||||
data: '',
|
||||
incarnationId: expect.any(String),
|
||||
sequenceChars: query.length,
|
||||
seq: query.length,
|
||||
transformed: true
|
||||
})
|
||||
expect(onData).toHaveBeenCalledWith({ id, data: 'prompt' })
|
||||
expect(onData).toHaveBeenCalledWith({
|
||||
id,
|
||||
data: 'prompt',
|
||||
incarnationId: expect.any(String)
|
||||
})
|
||||
await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({
|
||||
data: expect.not.stringContaining(']10;rgb')
|
||||
})
|
||||
@@ -510,6 +588,124 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('acknowledges exact held pauses and releases only the last idempotent lease', async () => {
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!))
|
||||
const client = (adapter as unknown as { client: DaemonClient }).client
|
||||
const requestSpy = vi.spyOn(client, 'request')
|
||||
|
||||
await expect(
|
||||
adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease-a')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease-a')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease-b')
|
||||
).resolves.toBe(true)
|
||||
expect(requestSpy).toHaveBeenCalledWith('pausePty', {
|
||||
sessionId: id,
|
||||
incarnationId,
|
||||
heldPauseToken: 'lease-a'
|
||||
})
|
||||
expect(lastSubprocess.pause).toHaveBeenCalledTimes(1)
|
||||
|
||||
await expect(
|
||||
adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease-a')
|
||||
).resolves.toBe(true)
|
||||
expect(lastSubprocess.resume).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease-b')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease-b')
|
||||
).resolves.toBe(true)
|
||||
expect(lastSubprocess.resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails closed for stale incarnations and pre-v35 daemon protocols', async () => {
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!))
|
||||
expect(adapter.supportsExactHeldProducerPause(id, 'stale-incarnation')).toBe(false)
|
||||
await expect(
|
||||
adapter.acquireExactHeldProducerPause(id, 'stale-incarnation', 'lease')
|
||||
).resolves.toBe(false)
|
||||
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 34 })
|
||||
const requestSpy = vi.spyOn((legacy as unknown as { client: DaemonClient }).client, 'request')
|
||||
try {
|
||||
expect(legacy.supportsExactHeldProducerPause(id, incarnationId!)).toBe(false)
|
||||
await expect(
|
||||
legacy.acquireExactHeldProducerPause(id, incarnationId!, 'lease')
|
||||
).resolves.toBe(false)
|
||||
expect(requestSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects partial or stale v35 lease payloads without applying a legacy pause', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const client = (adapter as unknown as { client: DaemonClient }).client
|
||||
|
||||
await expect(
|
||||
client.request('pausePty', { sessionId: id, incarnationId: 'missing-token' })
|
||||
).resolves.toEqual({ accepted: false })
|
||||
await expect(
|
||||
client.request('pausePty', {
|
||||
sessionId: id,
|
||||
incarnationId: 'stale-incarnation',
|
||||
heldPauseToken: 'lease'
|
||||
})
|
||||
).resolves.toEqual({ accepted: false })
|
||||
expect(lastSubprocess.pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases exact held pauses when the owning connection closes', async () => {
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!))
|
||||
await adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease')
|
||||
|
||||
;(adapter as unknown as { client: DaemonClient }).client.disconnect()
|
||||
|
||||
await waitFor(() => lastSubprocess.resume.mock.calls.length === 1)
|
||||
})
|
||||
|
||||
it('disconnects to unwind a remotely applied acquire whose acknowledgement is lost', async () => {
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!))
|
||||
const client = (adapter as unknown as { client: DaemonClient }).client
|
||||
const originalRequest = client.request.bind(client)
|
||||
vi.spyOn(client, 'request').mockImplementationOnce(async (type, payload) => {
|
||||
await originalRequest(type, payload)
|
||||
throw new Error('ack lost')
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease')
|
||||
).resolves.toBe(false)
|
||||
await waitFor(() => lastSubprocess.resume.mock.calls.length === 1)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not pretend an exact release succeeded when its acknowledgement is lost', async () => {
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!))
|
||||
await adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease')
|
||||
const client = (adapter as unknown as { client: DaemonClient }).client
|
||||
const originalRequest = client.request.bind(client)
|
||||
vi.spyOn(client, 'request').mockImplementationOnce(async (type, payload) => {
|
||||
await originalRequest(type, payload)
|
||||
throw new Error('ack lost')
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease')
|
||||
).resolves.toBe(false)
|
||||
await waitFor(() => lastSubprocess.resume.mock.calls.length === 1)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dead-endpoint write respawn (STA-2373)', () => {
|
||||
@@ -1432,24 +1628,24 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
const dataPayloads: { id: string; data: string }[] = []
|
||||
adapter.onData((payload) => dataPayloads.push(payload))
|
||||
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
lastSubprocess._simulateData('hello')
|
||||
|
||||
await waitFor(() => dataPayloads.length > 0)
|
||||
expect(dataPayloads[0]).toEqual({ id, data: 'hello' })
|
||||
expect(dataPayloads[0]).toEqual({ id, data: 'hello', incarnationId })
|
||||
})
|
||||
|
||||
it('coalesces burst data events before serializing daemon stream output', async () => {
|
||||
const dataPayloads: { id: string; data: string }[] = []
|
||||
adapter.onData((payload) => dataPayloads.push(payload))
|
||||
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
lastSubprocess._simulateData('a')
|
||||
lastSubprocess._simulateData('b')
|
||||
lastSubprocess._simulateData('c')
|
||||
|
||||
await waitFor(() => dataPayloads.length > 0)
|
||||
expect(dataPayloads).toEqual([{ id, data: 'abc' }])
|
||||
expect(dataPayloads).toEqual([{ id, data: 'abc', incarnationId }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1641,7 +1837,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
|
||||
describe('attach', () => {
|
||||
it('reattaches to existing session and receives events', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
// Create a second adapter simulating app restart
|
||||
const adapter2 = new DaemonPtyAdapter({ socketPath, tokenPath })
|
||||
@@ -1652,7 +1848,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
|
||||
lastSubprocess._simulateData('after-reattach')
|
||||
await waitFor(() => dataPayloads.length > 0)
|
||||
expect(dataPayloads[0]).toEqual({ id, data: 'after-reattach' })
|
||||
expect(dataPayloads[0]).toEqual({ id, data: 'after-reattach', incarnationId })
|
||||
|
||||
adapter2.dispose()
|
||||
})
|
||||
@@ -1790,6 +1986,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('binds management inventory to the route captured from that RPC result', async () => {
|
||||
const spawned = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const listed = (await adapter.listSessions()).find(
|
||||
(session) => session.sessionId === spawned.id
|
||||
)
|
||||
|
||||
expect(listed?.mutationRouteToken).toBe(adapter.getPtyMutationRouteToken(spawned.id))
|
||||
})
|
||||
|
||||
it('loads persisted Linux birth identity for audit observations', async () => {
|
||||
adapter.dispose()
|
||||
await server.shutdown()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
type DaemonPtyRequest<TType extends string, TPayload extends object> = {
|
||||
id: string
|
||||
type: TType
|
||||
payload: { sessionId: string } & TPayload
|
||||
}
|
||||
|
||||
type ExactIdentity = { incarnationId: string }
|
||||
type AuthorityIdentity = { authorityAccess: TerminalSessionAuthorityPtyAccess }
|
||||
|
||||
export type DaemonPtyOperationRequest =
|
||||
| DaemonPtyRequest<'write', { data: string }>
|
||||
| DaemonPtyRequest<'writeExact', ExactIdentity & { data: string }>
|
||||
| DaemonPtyRequest<'resize', { cols: number; rows: number }>
|
||||
| DaemonPtyRequest<'resizeExact', ExactIdentity & { cols: number; rows: number }>
|
||||
| DaemonPtyRequest<'kill', { immediate?: boolean }>
|
||||
| DaemonPtyRequest<'killExact', ExactIdentity & { immediate?: boolean }>
|
||||
| DaemonPtyRequest<'signalExact', ExactIdentity & { signal: string }>
|
||||
| DaemonPtyRequest<'clearBufferExact', ExactIdentity>
|
||||
| DaemonPtyRequest<'writeAuthorityExact', AuthorityIdentity & { data: string }>
|
||||
| DaemonPtyRequest<'resizeAuthorityExact', AuthorityIdentity & { cols: number; rows: number }>
|
||||
| DaemonPtyRequest<'killAuthorityExact', AuthorityIdentity & { immediate?: boolean }>
|
||||
| DaemonPtyRequest<'signalAuthorityExact', AuthorityIdentity & { signal: string }>
|
||||
| DaemonPtyRequest<'clearBufferAuthorityExact', AuthorityIdentity>
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { PtyProcessInfo } from '../providers/types'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import { sweepProviderPtysForWorktree } from '../runtime/worktree-pty-inventory-sweep'
|
||||
|
||||
type ExactDataEvent = {
|
||||
id: string
|
||||
data: string
|
||||
incarnationId?: string
|
||||
}
|
||||
|
||||
type AuthorityAdapterMock = DaemonPtyAdapter & {
|
||||
rows: PtyProcessInfo[]
|
||||
emitData: (payload: ExactDataEvent) => void
|
||||
emitIdentityChange: () => void
|
||||
}
|
||||
|
||||
function authorityAccess(
|
||||
physicalPtyId: string,
|
||||
ptyIncarnationId: string
|
||||
): TerminalSessionAuthorityPtyAccess {
|
||||
return {
|
||||
namespace: { authorityHostId: 'authority-host', namespaceId: 'namespace' },
|
||||
pane: { paneKey: physicalPtyId, paneGenerationId: 'renderer:1' },
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-incarnation',
|
||||
physicalPtyId,
|
||||
ptyIncarnationId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listed(
|
||||
id: string,
|
||||
incarnationId: string,
|
||||
token: object,
|
||||
worktreeId?: string
|
||||
): PtyProcessInfo {
|
||||
return {
|
||||
id,
|
||||
incarnationId,
|
||||
mutationRouteToken: token,
|
||||
terminalSessionAuthorityAccess: authorityAccess(id, incarnationId),
|
||||
cwd: '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter(rows: PtyProcessInfo[]): AuthorityAdapterMock {
|
||||
const dataListeners: ((payload: ExactDataEvent) => void)[] = []
|
||||
const identityListeners: (() => void)[] = []
|
||||
const adapter = {
|
||||
rows,
|
||||
protocolVersion: 34,
|
||||
listProcesses: vi.fn(async () => rows),
|
||||
getPtyMutationRouteToken: vi.fn(
|
||||
(id: string) => rows.find((row) => row.id === id)?.mutationRouteToken ?? null
|
||||
),
|
||||
supportsExactPtyOperations: vi.fn(() => true),
|
||||
getPtyMutationMode: vi.fn(() => 'exact'),
|
||||
writeExact: vi.fn(() => true),
|
||||
resizeExact: vi.fn(() => true),
|
||||
killExact: vi.fn(async () => true),
|
||||
sendSignalExact: vi.fn(async () => true),
|
||||
clearBufferExact: vi.fn(async () => true),
|
||||
writeAuthorityExact: vi.fn(() => true),
|
||||
resizeAuthorityExact: vi.fn(() => true),
|
||||
killAuthorityExact: vi.fn(async () => true),
|
||||
sendSignalAuthorityExact: vi.fn(async () => true),
|
||||
clearBufferAuthorityExact: vi.fn(async () => true),
|
||||
bindTerminalSessionAuthorityAccess: vi.fn(() => true),
|
||||
hasPty: vi.fn((id: string) => rows.some((row) => row.id === id)),
|
||||
onData: vi.fn((callback: (payload: ExactDataEvent) => void) => {
|
||||
dataListeners.push(callback)
|
||||
return () => {}
|
||||
}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onBackgroundStreamEvent: vi.fn(() => () => {}),
|
||||
onWriteUnavailable: vi.fn(() => () => {}),
|
||||
onDaemonIdentityChanged: vi.fn((callback: () => void) => {
|
||||
identityListeners.push(callback)
|
||||
return () => {}
|
||||
}),
|
||||
emitData: (payload: ExactDataEvent) => {
|
||||
for (const listener of dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
},
|
||||
emitIdentityChange: () => {
|
||||
for (const listener of identityListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
return adapter as unknown as AuthorityAdapterMock
|
||||
}
|
||||
|
||||
async function expectAuthorityOperations(
|
||||
router: DaemonPtyRouter,
|
||||
adapter: AuthorityAdapterMock,
|
||||
id: string,
|
||||
incarnationId: string
|
||||
): Promise<void> {
|
||||
const access = authorityAccess(id, incarnationId)
|
||||
expect(router.bindTerminalSessionAuthorityAccess(id, access)).toBe(true)
|
||||
expect(router.writeAuthorityExact(id, access, 'input')).toBe(true)
|
||||
expect(router.resizeAuthorityExact(id, access, 120, 40)).toBe(true)
|
||||
await expect(router.sendSignalAuthorityExact(id, access, 'SIGTERM')).resolves.toBe(true)
|
||||
await expect(router.clearBufferAuthorityExact(id, access)).resolves.toBe(true)
|
||||
await expect(router.killAuthorityExact(id, access, { immediate: true })).resolves.toBe(true)
|
||||
expect(adapter.writeAuthorityExact).toHaveBeenCalledWith(id, access, 'input')
|
||||
expect(adapter.resizeAuthorityExact).toHaveBeenCalledWith(id, access, 120, 40)
|
||||
expect(adapter.sendSignalAuthorityExact).toHaveBeenCalledWith(id, access, 'SIGTERM')
|
||||
expect(adapter.clearBufferAuthorityExact).toHaveBeenCalledWith(id, access)
|
||||
expect(adapter.killAuthorityExact).toHaveBeenCalledWith(id, access, { immediate: true })
|
||||
}
|
||||
|
||||
describe('DaemonPtyRouter authority routing', () => {
|
||||
it('forwards exact and authority operations to uniquely routed current and legacy adapters', async () => {
|
||||
const current = createAdapter([listed('current-session', 'current-incarnation', {})])
|
||||
const legacy = createAdapter([listed('legacy-session', 'legacy-incarnation', {})])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.listProcesses()
|
||||
|
||||
expect(router.writeExact('current-session', 'current-incarnation', 'exact')).toBe(true)
|
||||
expect(router.resizeExact('legacy-session', 'legacy-incarnation', 100, 30)).toBe(true)
|
||||
await expect(
|
||||
router.sendSignalExact('current-session', 'current-incarnation', 'SIGINT')
|
||||
).resolves.toBe(true)
|
||||
await expect(router.clearBufferExact('legacy-session', 'legacy-incarnation')).resolves.toBe(
|
||||
true
|
||||
)
|
||||
await expect(
|
||||
router.killExact('current-session', 'current-incarnation', { immediate: true })
|
||||
).resolves.toBe(true)
|
||||
await expectAuthorityOperations(router, current, 'current-session', 'current-incarnation')
|
||||
await expectAuthorityOperations(router, legacy, 'legacy-session', 'legacy-incarnation')
|
||||
})
|
||||
|
||||
it('fails closed for a same-id adapter collision', async () => {
|
||||
const access = authorityAccess('collision', 'incarnation')
|
||||
const current = createAdapter([listed('collision', 'incarnation', {})])
|
||||
const legacy = createAdapter([listed('collision', 'incarnation', {})])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
|
||||
const inventory = await router.listProcesses()
|
||||
|
||||
expect(inventory.every((row) => row.mutationRouteToken === undefined)).toBe(true)
|
||||
expect(router.getPtyMutationRouteToken('collision')).toBeNull()
|
||||
expect(router.writeExact('collision', 'incarnation', 'blocked')).toBe(false)
|
||||
expect(router.writeAuthorityExact('collision', access, 'blocked')).toBe(false)
|
||||
expect(router.bindTerminalSessionAuthorityAccess('collision', access)).toBe(false)
|
||||
expect(current.writeExact).not.toHaveBeenCalled()
|
||||
expect(legacy.writeExact).not.toHaveBeenCalled()
|
||||
expect(current.writeAuthorityExact).not.toHaveBeenCalled()
|
||||
expect(legacy.writeAuthorityExact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates exact routing when the owning adapter is replaced', async () => {
|
||||
const token = {}
|
||||
const access = authorityAccess('replaced', 'incarnation')
|
||||
const current = createAdapter([listed('replaced', 'incarnation', token)])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [] })
|
||||
const [row] = await router.listProcesses()
|
||||
current.emitIdentityChange()
|
||||
|
||||
expect(row?.mutationRouteToken).toBe(token)
|
||||
expect(router.getPtyMutationRouteToken('replaced')).toBeNull()
|
||||
expect(router.writeExact('replaced', 'incarnation', 'blocked')).toBe(false)
|
||||
expect(router.writeAuthorityExact('replaced', access, 'blocked')).toBe(false)
|
||||
expect(router.bindTerminalSessionAuthorityAccess('replaced', access)).toBe(false)
|
||||
})
|
||||
|
||||
it('uses authority-exact kill during a worktree inventory sweep', async () => {
|
||||
const worktreeId = 'repo-id::/worktree'
|
||||
const id = `${worktreeId}@@pty`
|
||||
const current = createAdapter([listed(id, 'incarnation', {}, worktreeId)])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [] })
|
||||
|
||||
await expect(
|
||||
sweepProviderPtysForWorktree(
|
||||
worktreeId,
|
||||
router,
|
||||
Date.now() + 5_000,
|
||||
Date.now() + 4_000,
|
||||
async (_ptyId, stop) => ({ stopped: await stop(), owner: true }),
|
||||
undefined,
|
||||
true,
|
||||
() => router.listProcesses()
|
||||
)
|
||||
).resolves.toBe(1)
|
||||
expect(current.killAuthorityExact).toHaveBeenCalledOnce()
|
||||
expect(current.killExact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the admitted incarnation through router data fanout', () => {
|
||||
const current = createAdapter([])
|
||||
const legacy = createAdapter([])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
const received: ExactDataEvent[] = []
|
||||
router.onData((payload) => received.push(payload))
|
||||
|
||||
legacy.emitData({ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' })
|
||||
|
||||
expect(received).toEqual([{ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' }])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
export type DaemonPtyRouterDataEvent = {
|
||||
id: string
|
||||
data: string
|
||||
incarnationId?: PtyIncarnationId
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { IPtyProvider, PtyHeldProducerPauseToken, PtyMutationMode } from '../providers/types'
|
||||
|
||||
export abstract class DaemonPtyExactOperationRouter {
|
||||
protected abstract exactProviderFor(id: string): IPtyProvider | null
|
||||
|
||||
supportsExactPtyOperations(id: string): boolean {
|
||||
return this.exactProviderFor(id)?.supportsExactPtyOperations?.(id) === true
|
||||
}
|
||||
|
||||
getPtyMutationMode(id: string): PtyMutationMode {
|
||||
return this.exactProviderFor(id)?.getPtyMutationMode?.(id) ?? 'unavailable'
|
||||
}
|
||||
|
||||
supportsExactHeldProducerPause(id: string, incarnationId: string): boolean {
|
||||
return this.exactProviderFor(id)?.supportsExactHeldProducerPause?.(id, incarnationId) === true
|
||||
}
|
||||
|
||||
async acquireExactHeldProducerPause(
|
||||
id: string,
|
||||
incarnationId: string,
|
||||
token: PtyHeldProducerPauseToken
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await this.exactProviderFor(id)?.acquireExactHeldProducerPause?.(
|
||||
id,
|
||||
incarnationId,
|
||||
token
|
||||
)) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
async releaseExactHeldProducerPause(
|
||||
id: string,
|
||||
incarnationId: string,
|
||||
token: PtyHeldProducerPauseToken
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await this.exactProviderFor(id)?.releaseExactHeldProducerPause?.(
|
||||
id,
|
||||
incarnationId,
|
||||
token
|
||||
)) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
writeExact(id: string, incarnationId: string, data: string): boolean {
|
||||
return this.exactProviderFor(id)?.writeExact?.(id, incarnationId, data) === true
|
||||
}
|
||||
|
||||
writeAuthorityExact(
|
||||
id: string,
|
||||
authorityAccess: TerminalSessionAuthorityPtyAccess,
|
||||
data: string
|
||||
): boolean {
|
||||
return this.exactProviderFor(id)?.writeAuthorityExact?.(id, authorityAccess, data) === true
|
||||
}
|
||||
|
||||
resizeExact(id: string, incarnationId: string, cols: number, rows: number): boolean {
|
||||
return this.exactProviderFor(id)?.resizeExact?.(id, incarnationId, cols, rows) === true
|
||||
}
|
||||
|
||||
resizeAuthorityExact(
|
||||
id: string,
|
||||
authorityAccess: TerminalSessionAuthorityPtyAccess,
|
||||
cols: number,
|
||||
rows: number
|
||||
): boolean {
|
||||
return (
|
||||
this.exactProviderFor(id)?.resizeAuthorityExact?.(id, authorityAccess, cols, rows) === true
|
||||
)
|
||||
}
|
||||
|
||||
async killExact(
|
||||
id: string,
|
||||
incarnationId: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<boolean> {
|
||||
return (await this.exactProviderFor(id)?.killExact?.(id, incarnationId, opts)) ?? false
|
||||
}
|
||||
|
||||
async killAuthorityExact(
|
||||
id: string,
|
||||
authorityAccess: TerminalSessionAuthorityPtyAccess,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await this.exactProviderFor(id)?.killAuthorityExact?.(id, authorityAccess, opts)) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
async sendSignalExact(id: string, incarnationId: string, signal: string): Promise<boolean> {
|
||||
return (await this.exactProviderFor(id)?.sendSignalExact?.(id, incarnationId, signal)) ?? false
|
||||
}
|
||||
|
||||
async sendSignalAuthorityExact(
|
||||
id: string,
|
||||
authorityAccess: TerminalSessionAuthorityPtyAccess,
|
||||
signal: string
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await this.exactProviderFor(id)?.sendSignalAuthorityExact?.(id, authorityAccess, signal)) ??
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
async clearBufferExact(id: string, incarnationId: string): Promise<boolean> {
|
||||
return (await this.exactProviderFor(id)?.clearBufferExact?.(id, incarnationId)) ?? false
|
||||
}
|
||||
|
||||
async clearBufferAuthorityExact(
|
||||
id: string,
|
||||
authorityAccess: TerminalSessionAuthorityPtyAccess
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await this.exactProviderFor(id)?.clearBufferAuthorityExact?.(id, authorityAccess)) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
bindTerminalSessionAuthorityAccess(
|
||||
id: string,
|
||||
authorityAccess: TerminalSessionAuthorityPtyAccess
|
||||
): boolean {
|
||||
return (
|
||||
this.exactProviderFor(id)?.bindTerminalSessionAuthorityAccess?.(id, authorityAccess) === true
|
||||
)
|
||||
}
|
||||
|
||||
getTerminalSessionAuthorityAccess(id: string): TerminalSessionAuthorityPtyAccess | null {
|
||||
return this.exactProviderFor(id)?.getTerminalSessionAuthorityAccess?.(id) ?? null
|
||||
}
|
||||
|
||||
getPtyMutationRouteToken(id: string): object | null {
|
||||
const provider = this.exactProviderFor(id)
|
||||
if (!provider?.getPtyMutationRouteToken) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return provider.getPtyMutationRouteToken(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { IPtyProvider, PtyProcessInfo } from '../providers/types'
|
||||
|
||||
export type DaemonPtyProviderInventory<TProvider extends IPtyProvider> = Readonly<{
|
||||
provider: TProvider
|
||||
processes: readonly PtyProcessInfo[]
|
||||
}>
|
||||
|
||||
type DaemonPtyInventoryRouting<TProvider extends IPtyProvider> = Readonly<{
|
||||
existingRouteIds: Iterable<string>
|
||||
recordRoute: (id: string, provider: TProvider, process: PtyProcessInfo) => void
|
||||
forgetRoute: (id: string) => void
|
||||
}>
|
||||
|
||||
export function reconcileDaemonPtyProviderInventory<TProvider extends IPtyProvider>(
|
||||
inventories: readonly DaemonPtyProviderInventory<TProvider>[],
|
||||
routing: DaemonPtyInventoryRouting<TProvider>
|
||||
): PtyProcessInfo[] {
|
||||
const candidatesById = new Map<string, { provider: TProvider; process: PtyProcessInfo }[]>()
|
||||
for (const { provider, processes } of inventories) {
|
||||
for (const process of processes) {
|
||||
const candidates = candidatesById.get(process.id) ?? []
|
||||
candidates.push({ provider, process })
|
||||
candidatesById.set(process.id, candidates)
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of routing.existingRouteIds) {
|
||||
if (!candidatesById.has(id)) {
|
||||
routing.forgetRoute(id)
|
||||
}
|
||||
}
|
||||
for (const [id, candidates] of candidatesById) {
|
||||
const candidate = candidates.length === 1 ? candidates[0] : undefined
|
||||
if (candidate) {
|
||||
routing.recordRoute(id, candidate.provider, candidate.process)
|
||||
} else {
|
||||
routing.forgetRoute(id)
|
||||
}
|
||||
}
|
||||
|
||||
return inventories.flatMap(({ provider, processes }) =>
|
||||
processes.map((process) => {
|
||||
const listedRouteToken = process.mutationRouteToken
|
||||
const unique = candidatesById.get(process.id)?.length === 1
|
||||
const routeToken = unique
|
||||
? currentListedRouteToken(provider, process.id, listedRouteToken)
|
||||
: null
|
||||
if (routeToken || listedRouteToken === undefined) {
|
||||
return process
|
||||
}
|
||||
const { mutationRouteToken: _discarded, ...withoutRouteToken } = process
|
||||
return withoutRouteToken
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function currentListedRouteToken(
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
listed: object | undefined
|
||||
): object | null {
|
||||
if (!listed || !provider.getPtyMutationRouteToken) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return provider.getPtyMutationRouteToken(id) === listed ? listed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ function createAdapter(
|
||||
const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] =
|
||||
[]
|
||||
const identityChangeListeners: (() => void)[] = []
|
||||
const mutationRouteToken = Object.freeze({ label })
|
||||
return {
|
||||
protocolVersion,
|
||||
supportsGitCredentialGuardHost: () =>
|
||||
@@ -67,12 +68,19 @@ function createAdapter(
|
||||
listProcesses: vi.fn(async () =>
|
||||
sessions.map((id) => ({
|
||||
id,
|
||||
mutationRouteToken,
|
||||
cwd: '',
|
||||
title: label
|
||||
}))
|
||||
),
|
||||
getPtyMutationRouteToken: vi.fn((id: string) =>
|
||||
sessions.includes(id) ? mutationRouteToken : null
|
||||
),
|
||||
hasPty: vi.fn((id: string) => sessions.includes(id)),
|
||||
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
supportsExactHeldProducerPause: vi.fn((id: string) => sessions.includes(id)),
|
||||
acquireExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
releaseExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
write: vi.fn((id: string, data: string) => {
|
||||
writes.push({ id, data })
|
||||
}),
|
||||
@@ -683,6 +691,66 @@ describe('DaemonPtyRouter', () => {
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy unavailable')
|
||||
})
|
||||
|
||||
it('stamps inventory with the uniquely selected adapter route token', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
|
||||
const [listed] = await router.listProcesses()
|
||||
|
||||
expect(listed?.mutationRouteToken).toBe(legacy.getPtyMutationRouteToken('legacy-session'))
|
||||
expect(router.getPtyMutationRouteToken('legacy-session')).toBe(listed?.mutationRouteToken)
|
||||
})
|
||||
|
||||
it('routes exact held-pause admission and leases to the discovered owner', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
expect(router.supportsExactHeldProducerPause('legacy-session', 'incarnation')).toBe(true)
|
||||
await expect(
|
||||
router.acquireExactHeldProducerPause('legacy-session', 'incarnation', 'lease')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
router.releaseExactHeldProducerPause('legacy-session', 'incarnation', 'lease')
|
||||
).resolves.toBe(true)
|
||||
expect(legacy.acquireExactHeldProducerPause).toHaveBeenCalledWith(
|
||||
'legacy-session',
|
||||
'incarnation',
|
||||
'lease'
|
||||
)
|
||||
expect(current.acquireExactHeldProducerPause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates a listed token when ownership moves between daemon adapters', async () => {
|
||||
const currentSessions: string[] = []
|
||||
const legacySessions = ['moving-session']
|
||||
const current = createAdapter('current', currentSessions)
|
||||
const legacy = createAdapter('legacy', legacySessions)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
const [before] = await router.listProcesses()
|
||||
|
||||
legacySessions.length = 0
|
||||
currentSessions.push('moving-session')
|
||||
const [after] = await router.listProcesses()
|
||||
|
||||
expect(before?.mutationRouteToken).not.toBe(after?.mutationRouteToken)
|
||||
expect(router.getPtyMutationRouteToken('moving-session')).toBe(after?.mutationRouteToken)
|
||||
})
|
||||
|
||||
it('withholds route tokens for colliding daemon inventories', async () => {
|
||||
const current = createAdapter('current', ['collision'])
|
||||
const legacy = createAdapter('legacy', ['collision'])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
|
||||
const listed = await router.listProcesses()
|
||||
|
||||
expect(listed).toHaveLength(2)
|
||||
expect(listed.every((entry) => entry.mutationRouteToken === undefined)).toBe(true)
|
||||
expect(router.getPtyMutationRouteToken('collision')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a legacy adapter that exits after construction in fail-closed aggregates', async () => {
|
||||
const current = createAdapter('current', ['current-session'])
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
|
||||
@@ -12,8 +12,10 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
|
||||
import { reconcileDaemonPtyProviderInventory } from './daemon-pty-router-inventory'
|
||||
import { DaemonPtyExactOperationRouter } from './daemon-pty-router-exact-operations'
|
||||
|
||||
export class DaemonPtyRouter implements IPtyProvider {
|
||||
export class DaemonPtyRouter extends DaemonPtyExactOperationRouter implements IPtyProvider {
|
||||
private current: DaemonPtyAdapter
|
||||
private legacy: DaemonPtyAdapter[]
|
||||
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
|
||||
@@ -21,6 +23,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
|
||||
|
||||
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
||||
super()
|
||||
this.current = opts.current
|
||||
this.legacy = opts.legacy
|
||||
this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters)
|
||||
@@ -193,9 +196,17 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
|
||||
// cannot provide a trustworthy process list.
|
||||
const results = await Promise.all(
|
||||
this.allAdapters().map((adapter) => adapter.listProcesses(opts))
|
||||
this.allAdapters().map(async (adapter) => ({
|
||||
provider: adapter,
|
||||
processes: await adapter.listProcesses(opts)
|
||||
}))
|
||||
)
|
||||
return results.flat()
|
||||
return reconcileDaemonPtyProviderInventory(results, {
|
||||
existingRouteIds: this.sessionAdapters.keys(),
|
||||
recordRoute: (id, adapter, process) =>
|
||||
this.ownerResolver.recordRoute(id, adapter, process.incarnationId),
|
||||
forgetRoute: (id) => this.ownerResolver.forgetRoute(id)
|
||||
})
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
@@ -313,6 +324,10 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return this.sessionAdapters.get(sessionId) ?? this.current
|
||||
}
|
||||
|
||||
protected exactProviderFor(sessionId: string): DaemonPtyAdapter | null {
|
||||
return this.sessionAdapters.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
private adapterForInspection(sessionId: string): DaemonPtyAdapter {
|
||||
const adapter =
|
||||
this.sessionAdapters.get(sessionId) ??
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DaemonPtyStreamBindingState } from './daemon-pty-stream-binding-state'
|
||||
|
||||
function source(incarnationId: string, streamBindingNonce: string) {
|
||||
return { incarnationId, streamBindingNonce }
|
||||
}
|
||||
|
||||
describe('DaemonPtyStreamBindingState', () => {
|
||||
it('admits the first source marker before its control reply', () => {
|
||||
const state = new DaemonPtyStreamBindingState()
|
||||
state.begin('nonce-a')
|
||||
|
||||
expect(state.acceptMarker('session-1', source('inc-a', 'nonce-a'))).toEqual(
|
||||
source('inc-a', 'nonce-a')
|
||||
)
|
||||
expect(state.sourceFor('session-1')).toEqual(source('inc-a', 'nonce-a'))
|
||||
expect(state.acceptResponse('nonce-a', 'session-1', 'inc-a', 'nonce-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the latest ordered concurrent marker own the stream', () => {
|
||||
const state = new DaemonPtyStreamBindingState()
|
||||
state.begin('nonce-a')
|
||||
state.begin('nonce-b')
|
||||
|
||||
state.acceptMarker('session-1', source('inc-a', 'nonce-a'))
|
||||
state.acceptMarker('session-1', source('inc-b', 'nonce-b'))
|
||||
expect(state.acceptResponse('nonce-a', 'session-1', 'inc-a', 'nonce-a')).toBe(true)
|
||||
expect(state.sourceFor('session-1')).toEqual(source('inc-b', 'nonce-b'))
|
||||
expect(state.acceptResponse('nonce-b', 'session-1', 'inc-b', 'nonce-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects stale markers, output bindings, and exits', () => {
|
||||
const state = new DaemonPtyStreamBindingState()
|
||||
state.begin('nonce-current')
|
||||
state.acceptMarker('session-1', source('inc-current', 'nonce-current'))
|
||||
|
||||
expect(state.acceptMarker('session-1', source('inc-stale', 'nonce-stale'))).toBeNull()
|
||||
expect(state.sourceFor('session-1')).toEqual(source('inc-current', 'nonce-current'))
|
||||
expect(state.admitsEvent('session-1')).toBe(true)
|
||||
expect(state.admitsEvent('stale-session')).toBe(false)
|
||||
expect(state.admitsExit('session-1', 'inc-stale')).toBe(false)
|
||||
expect(state.admitsExit('session-1', 'inc-current')).toBe(true)
|
||||
})
|
||||
|
||||
it('revokes a marker when its final control reply disagrees', () => {
|
||||
const state = new DaemonPtyStreamBindingState()
|
||||
state.begin('nonce-a')
|
||||
state.acceptMarker('session-1', source('inc-a', 'nonce-a'))
|
||||
|
||||
expect(state.acceptResponse('nonce-a', 'session-1', 'inc-b', 'nonce-a')).toBe(false)
|
||||
expect(state.sourceFor('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('revokes a nonce that emits conflicting source markers', () => {
|
||||
const state = new DaemonPtyStreamBindingState()
|
||||
state.begin('nonce-a')
|
||||
state.acceptMarker('session-1', source('inc-a', 'nonce-a'))
|
||||
|
||||
expect(state.acceptMarker('session-2', source('inc-b', 'nonce-a'))).toBeNull()
|
||||
expect(state.sourceFor('session-1')).toBeUndefined()
|
||||
expect(state.sourceFor('session-2')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bounds response-only bindings without evicting a live transaction', () => {
|
||||
const state = new DaemonPtyStreamBindingState(2)
|
||||
state.begin('nonce-a')
|
||||
state.begin('nonce-b')
|
||||
expect(state.acceptResponse('nonce-a', 'session-1', 'inc-a', 'nonce-a')).toBe(true)
|
||||
|
||||
expect(() => state.begin('nonce-c')).toThrow('daemon_stream_binding_capacity_exceeded')
|
||||
expect(state.acceptMarker('session-1', source('inc-a', 'nonce-a'))).toEqual(
|
||||
source('inc-a', 'nonce-a')
|
||||
)
|
||||
expect(() => state.begin('nonce-c')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import {
|
||||
isPtyStreamBindingNonce,
|
||||
isPtyStreamSource,
|
||||
type PtyStreamSource
|
||||
} from '../../shared/pty-stream-binding-protocol'
|
||||
|
||||
type BoundPtyStreamSource = PtyStreamSource & { sessionId: string }
|
||||
|
||||
type PendingStreamBinding = {
|
||||
marker?: BoundPtyStreamSource
|
||||
response?: BoundPtyStreamSource
|
||||
}
|
||||
|
||||
export class DaemonPtyStreamBindingState {
|
||||
private readonly pendingByNonce = new Map<string, PendingStreamBinding>()
|
||||
private readonly currentBySessionId = new Map<string, BoundPtyStreamSource>()
|
||||
|
||||
constructor(private readonly maxPendingBindings = 4_096) {}
|
||||
|
||||
begin(streamBindingNonce: string): void {
|
||||
if (
|
||||
!isPtyStreamBindingNonce(streamBindingNonce) ||
|
||||
this.pendingByNonce.has(streamBindingNonce)
|
||||
) {
|
||||
throw new Error('daemon_stream_binding_nonce_invalid')
|
||||
}
|
||||
if (this.pendingByNonce.size >= this.maxPendingBindings) {
|
||||
throw new Error('daemon_stream_binding_capacity_exceeded')
|
||||
}
|
||||
this.pendingByNonce.set(streamBindingNonce, {})
|
||||
}
|
||||
|
||||
acceptMarker(sessionId: string, value: unknown): PtyStreamSource | null {
|
||||
if (!sessionId || !isPtyStreamSource(value)) {
|
||||
return null
|
||||
}
|
||||
const pending = this.pendingByNonce.get(value.streamBindingNonce)
|
||||
if (!pending) {
|
||||
return null
|
||||
}
|
||||
const marker = { sessionId, ...value }
|
||||
if (
|
||||
(pending.marker && !sameBoundSource(pending.marker, marker)) ||
|
||||
(pending.response && !sameBoundSource(pending.response, marker))
|
||||
) {
|
||||
this.cancel(value.streamBindingNonce)
|
||||
return null
|
||||
}
|
||||
pending.marker = marker
|
||||
this.currentBySessionId.set(sessionId, marker)
|
||||
this.settleIfComplete(value.streamBindingNonce, pending)
|
||||
return value
|
||||
}
|
||||
|
||||
acceptResponse(
|
||||
streamBindingNonce: string,
|
||||
sessionId: string,
|
||||
incarnationId: unknown,
|
||||
echoedStreamBindingNonce: unknown
|
||||
): boolean {
|
||||
const pending = this.pendingByNonce.get(streamBindingNonce)
|
||||
if (
|
||||
!pending ||
|
||||
echoedStreamBindingNonce !== streamBindingNonce ||
|
||||
!isPtyIncarnationId(incarnationId) ||
|
||||
!sessionId
|
||||
) {
|
||||
this.cancel(streamBindingNonce)
|
||||
return false
|
||||
}
|
||||
const response = { sessionId, incarnationId, streamBindingNonce }
|
||||
if (pending.marker && !sameBoundSource(pending.marker, response)) {
|
||||
this.cancel(streamBindingNonce)
|
||||
return false
|
||||
}
|
||||
pending.response = response
|
||||
this.settleIfComplete(streamBindingNonce, pending)
|
||||
return true
|
||||
}
|
||||
|
||||
cancel(streamBindingNonce: string): void {
|
||||
const pending = this.pendingByNonce.get(streamBindingNonce)
|
||||
this.pendingByNonce.delete(streamBindingNonce)
|
||||
if (!pending?.marker) {
|
||||
return
|
||||
}
|
||||
const current = this.currentBySessionId.get(pending.marker.sessionId)
|
||||
if (current?.streamBindingNonce === streamBindingNonce) {
|
||||
this.currentBySessionId.delete(pending.marker.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
sourceFor(sessionId: string): PtyStreamSource | undefined {
|
||||
const source = this.currentBySessionId.get(sessionId)
|
||||
return source
|
||||
? {
|
||||
incarnationId: source.incarnationId,
|
||||
streamBindingNonce: source.streamBindingNonce
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
admittedIncarnationId(sessionId: string): string | undefined {
|
||||
return this.currentBySessionId.get(sessionId)?.incarnationId
|
||||
}
|
||||
|
||||
admitsEvent(sessionId: string): boolean {
|
||||
return this.currentBySessionId.has(sessionId)
|
||||
}
|
||||
|
||||
admitsExit(sessionId: string, incarnationId: unknown): boolean {
|
||||
const source = this.currentBySessionId.get(sessionId)
|
||||
return Boolean(source && source.incarnationId === incarnationId)
|
||||
}
|
||||
|
||||
forget(sessionId: string, incarnationId?: string): void {
|
||||
const source = this.currentBySessionId.get(sessionId)
|
||||
if (!source || (incarnationId && source.incarnationId !== incarnationId)) {
|
||||
return
|
||||
}
|
||||
this.currentBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pendingByNonce.clear()
|
||||
this.currentBySessionId.clear()
|
||||
}
|
||||
|
||||
private settleIfComplete(streamBindingNonce: string, pending: PendingStreamBinding): void {
|
||||
if (pending.marker && pending.response) {
|
||||
this.pendingByNonce.delete(streamBindingNonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameBoundSource(left: BoundPtyStreamSource, right: BoundPtyStreamSource): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.incarnationId === right.incarnationId &&
|
||||
left.streamBindingNonce === right.streamBindingNonce
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import type { DaemonRequest } from './types'
|
||||
import { DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST } from './daemon-terminal-authority-consumer-requests'
|
||||
import {
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM,
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
type TerminalAuthorityNamespaceAdmissionChallenge
|
||||
} from '../../shared/terminal-session-authority-consumer-proof'
|
||||
import {
|
||||
createTerminalAuthorityConsumerProof,
|
||||
createTerminalAuthorityProofEphemeralKeypair
|
||||
} from '../session-authority/terminal-session-authority-consumer-proof'
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
|
||||
type DaemonServerRouter = {
|
||||
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
type DaemonServerAuthorityState = DaemonServerRouter & {
|
||||
releaseAuthenticatedPolicyTransport(client: unknown): void
|
||||
clients: Map<string, unknown>
|
||||
}
|
||||
|
||||
const AUTHORITY_HOST_ID = 'authority-host:daemon-rollback-test'
|
||||
const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair()
|
||||
|
||||
describe('dead terminal authority daemon requests', () => {
|
||||
let server: DaemonServer
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.shutdown()
|
||||
})
|
||||
|
||||
it('rejects the retired proofless policy-consumer request before any mutation', async () => {
|
||||
server = new DaemonServer({
|
||||
socketPath: 'unused-daemon-socket',
|
||||
tokenPath: 'unused-daemon-token',
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('subprocess spawn is not part of this request')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
(server as unknown as DaemonServerRouter).routeRequest('client-1', {
|
||||
id: 'retired-policy-consumer-request',
|
||||
type: 'retireTerminalAuthorityPolicyConsumer',
|
||||
payload: undefined
|
||||
} as unknown as DaemonRequest)
|
||||
).rejects.toThrow('terminal authority policy consumer retirement is unsupported')
|
||||
})
|
||||
|
||||
it('reports joined primary and rollback failures when transport cleanup fails', async () => {
|
||||
const rollbackFailure = new Error('transport rollback failed')
|
||||
const rollback = vi.fn(async (): Promise<void> => {
|
||||
throw rollbackFailure
|
||||
})
|
||||
const onFailure = vi.fn()
|
||||
server = new DaemonServer({
|
||||
socketPath: 'unused-daemon-socket',
|
||||
tokenPath: 'unused-daemon-token',
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('subprocess spawn is not part of this request')
|
||||
},
|
||||
onTerminalSessionAuthorityFailure: onFailure
|
||||
})
|
||||
|
||||
const daemon = server as unknown as DaemonServerAuthorityState
|
||||
daemon.releaseAuthenticatedPolicyTransport({
|
||||
authorityConsumerTransport: { token: {} },
|
||||
authorityPendingPreparations: new Set([
|
||||
{
|
||||
active: true,
|
||||
preparation: { rollback },
|
||||
acceptances: { close: vi.fn() }
|
||||
}
|
||||
]),
|
||||
authorityNamespaceSessions: new Map()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(onFailure).toHaveBeenCalledOnce())
|
||||
expect(rollback).toHaveBeenCalledOnce()
|
||||
const failure = onFailure.mock.calls[0]?.[0]
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).errors).toEqual([
|
||||
expect.objectContaining({
|
||||
message: 'terminal authority consumer transport released during admission'
|
||||
}),
|
||||
rollbackFailure
|
||||
])
|
||||
})
|
||||
|
||||
it('returns joined primary and rollback failures from a grant cleanup failure', async () => {
|
||||
const { challenge, proof } = admissionProof()
|
||||
const primaryFailure = new Error('grant commit failed')
|
||||
const rollbackFailure = new Error('grant rollback failed')
|
||||
const preparation = {
|
||||
grant: {},
|
||||
policyConsumer: {},
|
||||
commit: vi.fn(async () => {
|
||||
throw primaryFailure
|
||||
}),
|
||||
rollback: vi.fn(async () => {
|
||||
throw rollbackFailure
|
||||
})
|
||||
}
|
||||
const owner = {
|
||||
prepareAuthenticatedPolicyConsumerNamespace: vi.fn(async () => preparation),
|
||||
releaseAuthenticatedPolicyConsumerTransport: vi.fn()
|
||||
} as unknown as TerminalSessionAuthorityPtyOwner
|
||||
server = new DaemonServer({
|
||||
socketPath: 'unused-daemon-socket',
|
||||
tokenPath: 'unused-daemon-token',
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('subprocess spawn is not part of this request')
|
||||
},
|
||||
terminalSessionAuthority: { ptyOwner: owner }
|
||||
})
|
||||
|
||||
const daemon = server as unknown as DaemonServerAuthorityState
|
||||
daemon.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket: { destroy: vi.fn() },
|
||||
streamSocket: { destroy: vi.fn() },
|
||||
authenticatedPairEstablished: true,
|
||||
capabilities: {
|
||||
terminalAuthorityConsumerProof: { version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION }
|
||||
},
|
||||
authorityConsumerTransport: {
|
||||
connectionGrantId: challenge.connectionGrantId,
|
||||
principal: challenge.authenticatedTransportPrincipal,
|
||||
capability: challenge.authenticatedTransportCapability,
|
||||
token: {}
|
||||
},
|
||||
authorityPendingPreparations: new Set()
|
||||
})
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await daemon.routeRequest('client-1', {
|
||||
id: 'grant-request',
|
||||
type: DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST,
|
||||
payload: proof
|
||||
} as DaemonRequest)
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).errors).toEqual([primaryFailure, rollbackFailure])
|
||||
expect(preparation.rollback).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
function admissionProof(): {
|
||||
challenge: TerminalAuthorityNamespaceAdmissionChallenge
|
||||
proof: ReturnType<typeof createTerminalAuthorityConsumerProof>
|
||||
} {
|
||||
const hostKeypair = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const challenge: TerminalAuthorityNamespaceAdmissionChallenge = Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM,
|
||||
namespace: Object.freeze({
|
||||
authorityHostId: AUTHORITY_HOST_ID,
|
||||
namespaceId: 'namespace:daemon-rollback-test'
|
||||
}),
|
||||
appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64'),
|
||||
candidateProcessIncarnationId: 'app-process:daemon-rollback-test',
|
||||
candidateSessionNonce: 'app-session:daemon-rollback-test',
|
||||
requestId: 'app-request:daemon-rollback-test',
|
||||
intent: 'first',
|
||||
currentAdmissionCas: 'admission-cas:daemon-rollback-test',
|
||||
connectionGrantId: 'daemon-grant:daemon-rollback-test',
|
||||
authenticatedTransportPrincipal: 'daemon-principal:daemon-rollback-test',
|
||||
authenticatedTransportCapability: 'daemon-capability:daemon-rollback-test',
|
||||
hostEphemeralPublicKeyB64: Buffer.from(hostKeypair.publicKey).toString('base64'),
|
||||
expiresAtMs: Date.now() + 30_000
|
||||
})
|
||||
return { challenge, proof: createTerminalAuthorityConsumerProof(challenge, APP_KEYPAIR) }
|
||||
}
|
||||
+1036
-50
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,50 @@ function nonSentinelWrites(streamSocket: { write: ReturnType<typeof vi.fn> }): P
|
||||
}
|
||||
|
||||
describe('DaemonStreamDataBatcher', () => {
|
||||
it('settles an exit only after prior session data reaches the stream sink', async () => {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
const callbacks: ((error?: Error | null) => void)[] = []
|
||||
streamSocket.write.mockImplementation((_line, callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
callbacks.push(callback)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
batcher.enqueue('client-1', 'session-1', 'final output')
|
||||
const settled = batcher.enqueueSettledControlEvent('client-1', 'session-1', {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId: 'session-1',
|
||||
payload: { code: 0, incarnationId: 'incarnation-1' }
|
||||
})
|
||||
|
||||
expect(nonSentinelWrites(streamSocket).map((entry) => entry.event)).toEqual(['data', 'exit'])
|
||||
let completed = false
|
||||
void settled.then(() => {
|
||||
completed = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(completed).toBe(false)
|
||||
callbacks.at(-1)?.()
|
||||
await expect(settled).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a pending settled event when its stream queue is cleared', async () => {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
streamSocket.writableLength = 128 * 1024
|
||||
const pending = batcher.enqueueSettledControlEvent('client-1', 'session-1', {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId: 'session-1',
|
||||
payload: { code: 0 }
|
||||
})
|
||||
|
||||
batcher.clear('client-1')
|
||||
|
||||
await expect(pending).rejects.toThrow('stream queue was cleared')
|
||||
})
|
||||
|
||||
it('coalesces background output before writing daemon stream events', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -115,6 +159,41 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses per-session accounting without scanning the hot-path queue', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher } = createBatcher()
|
||||
batcher.enqueue('client-1', 'session-1', 'bulk')
|
||||
const pending = (
|
||||
batcher as unknown as {
|
||||
pendingByClient: Map<
|
||||
string,
|
||||
{ queue: unknown[]; queuedCharsBySession: Map<string, number> }
|
||||
>
|
||||
}
|
||||
).pendingByClient.get('client-1')!
|
||||
pending.queue = new Proxy(pending.queue, {
|
||||
get(target, property, receiver) {
|
||||
if (property === Symbol.iterator) {
|
||||
throw new Error('hot-path queue scan')
|
||||
}
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
})
|
||||
pending.queuedCharsBySession.set('session-1', 2048)
|
||||
|
||||
expect(() =>
|
||||
batcher.enqueue('client-1', 'session-1', 'echo', {
|
||||
flushImmediately: true,
|
||||
flushMaxChars: 1024
|
||||
})
|
||||
).not.toThrow()
|
||||
batcher.clear()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('flushes interactive output for one session while another session has large pending output', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -500,6 +579,26 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('writes one source marker between old and newly attached output', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
batcher.enqueue('client-1', 'session-1', 'old')
|
||||
batcher.establishSessionSource('client-1', 'session-1', {
|
||||
incarnationId: 'inc-new',
|
||||
streamBindingNonce: 'nonce-new'
|
||||
})
|
||||
batcher.enqueue('client-1', 'session-1', 'new')
|
||||
vi.advanceTimersByTime(2)
|
||||
|
||||
const messages = nonSentinelWrites(streamSocket)
|
||||
expect(messages.map((message) => message.event)).toEqual(['data', 'sessionSource', 'data'])
|
||||
expect(messages.filter((message) => message.event === 'sessionSource')).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("holds a control event behind its session's held bulk (order latch)", () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import { NDJSON_MAX_LINE_BYTES } from './ndjson'
|
||||
import {
|
||||
clampToSafeSplitIndex,
|
||||
encodeStreamDataEvent,
|
||||
writeStreamDataEvents
|
||||
} from './daemon-stream-data-split'
|
||||
flushDaemonStreamData,
|
||||
writeDaemonStreamControl,
|
||||
type DaemonStreamDataFlushArguments
|
||||
} from './daemon-stream-data-flush'
|
||||
import { flushDaemonStreamSession } from './daemon-stream-session-flush'
|
||||
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
import type { DaemonEvent } from './types'
|
||||
import type { PtyStreamSource } from '../../shared/pty-stream-binding-protocol'
|
||||
import { appendDaemonStreamData, type DaemonStreamEnqueueOptions } from './daemon-stream-data-entry'
|
||||
import {
|
||||
evaluateDroppableEnqueue,
|
||||
@@ -21,17 +22,6 @@ type StreamDataClient = {
|
||||
// 2ms: each chunk waits a half-window here AND again in main's PTY batch; a smaller interval still coalesces bursts while cutting the fixed latency tax (~8ms of the measured ~19ms DSR-under-load latency).
|
||||
const STREAM_DATA_BATCH_INTERVAL_MS = 2
|
||||
|
||||
// Shallow socket: the stream is one FIFO, so a deep buffer buries a visible pane's echo behind other panes' bulk; bulk stops here and is HELD (flushSession can jump it), bounding echo latency.
|
||||
// 128KB stays above the socket's ~16KB highWaterMark so a held state implies a false write() and thus a guaranteed 'drain' wake-up.
|
||||
const SHALLOW_SOCKET_WRITE_GATE_BYTES =
|
||||
process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024
|
||||
// Sliced writes: a coalesced entry can grow to megabytes; writing it whole would re-deepen the socket past the gate in one call.
|
||||
const BULK_WRITE_SLICE_CHARS = 64 * 1024
|
||||
// Safety valve: past this, write through — bounded daemon memory beats bounded echo latency in the extreme. Must sit FAR above the pacer's pause watermark + overshoot (~5MB) or an engaged valve buries interactive echo behind the whole backlog.
|
||||
const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024
|
||||
// Small-session bypass: a few-KB session (echo, redraws, query replies) is never the flood, so it must not wait FIFO behind others' megabytes; backstops the 100ms interactive fast-path, which misses under event-loop load.
|
||||
const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024
|
||||
|
||||
type DaemonStreamDataBatcherOptions = {
|
||||
maxLineBytes?: number
|
||||
/** Fires after each stream-socket write — the only place backlog grows, so the backlog pacer checks its watermark here. */
|
||||
@@ -44,6 +34,7 @@ type DaemonStreamDataBatcherOptions = {
|
||||
|
||||
export class DaemonStreamDataBatcher {
|
||||
private pendingByClient = new Map<string, PendingStreamDataBatch>()
|
||||
private pendingSettlementsByClient = new Map<string, Set<(error: Error | null) => void>>()
|
||||
private getClient: (clientId: string) => StreamDataClient | undefined
|
||||
private maxLineBytes: number
|
||||
private onAfterSocketWrite: (() => void) | undefined
|
||||
@@ -86,7 +77,7 @@ export class DaemonStreamDataBatcher {
|
||||
|
||||
if (
|
||||
options.flushImmediately === true &&
|
||||
this.queuedCharsForSession(batch, sessionId) <=
|
||||
(batch.queuedCharsBySession.get(sessionId) ?? 0) <=
|
||||
(options.flushMaxChars ?? Number.POSITIVE_INFINITY)
|
||||
) {
|
||||
this.flushSession(clientId, sessionId)
|
||||
@@ -110,6 +101,56 @@ export class DaemonStreamDataBatcher {
|
||||
}
|
||||
}
|
||||
|
||||
enqueueSettledControlEvent(
|
||||
clientId: string,
|
||||
sessionId: string,
|
||||
control: DaemonEvent
|
||||
): Promise<void> {
|
||||
const client = this.getClient(clientId)
|
||||
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
||||
return Promise.reject(new Error('daemon stream consumer is unavailable'))
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const batch = this.getOrCreateBatch(clientId)
|
||||
const pending = this.pendingSettlementsByClient.get(clientId) ?? new Set()
|
||||
let settled = false
|
||||
const settle = (error: Error | null): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
pending.delete(settle)
|
||||
if (pending.size === 0) {
|
||||
this.pendingSettlementsByClient.delete(clientId)
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
pending.add(settle)
|
||||
this.pendingSettlementsByClient.set(clientId, pending)
|
||||
batch.queue.push({
|
||||
sessionId,
|
||||
data: '',
|
||||
control,
|
||||
settle
|
||||
})
|
||||
this.flushSession(clientId, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
establishSessionSource(clientId: string, sessionId: string, source: PtyStreamSource): void {
|
||||
this.flushSession(clientId, sessionId)
|
||||
this.enqueueControlEvent(clientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionSource',
|
||||
sessionId,
|
||||
payload: source
|
||||
})
|
||||
}
|
||||
|
||||
refreshSessionDroppability(sessionId: string): void {
|
||||
const droppable = this.isSessionDroppable(sessionId)
|
||||
refreshDroppableSessionMembership(this.pendingByClient.values(), sessionId, droppable)
|
||||
@@ -135,128 +176,19 @@ export class DaemonStreamDataBatcher {
|
||||
}
|
||||
|
||||
flush(clientId: string): void {
|
||||
const batch = this.pendingByClient.get(clientId)
|
||||
if (!batch) {
|
||||
return
|
||||
}
|
||||
|
||||
if (batch.timer) {
|
||||
clearTimeout(batch.timer)
|
||||
batch.timer = null
|
||||
}
|
||||
|
||||
const client = this.getClient(clientId)
|
||||
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
||||
// A vanished stream socket drops the batch — the model owns the bytes and reconnect restores from a snapshot.
|
||||
this.pendingByClient.delete(clientId)
|
||||
return
|
||||
}
|
||||
|
||||
const socket = client.streamSocket
|
||||
// A session that held an entry must hold all its later entries this pass — writing around a held entry would reorder that session's bytes.
|
||||
const heldSessions = new Set<string>()
|
||||
const retained: PendingStreamDataBatch['queue'] = []
|
||||
while (batch.queue.length > 0) {
|
||||
const entry = batch.queue[0]
|
||||
if (entry.control) {
|
||||
// Control entries only respect the held-session order latch; at ~100B, writing them onto a deep socket is as harmless as the small-session bypass.
|
||||
if (heldSessions.has(entry.sessionId)) {
|
||||
retained.push(entry)
|
||||
batch.queue.shift()
|
||||
continue
|
||||
}
|
||||
batch.queue.shift()
|
||||
socket.write(encodeNdjson(entry.control))
|
||||
this.onAfterSocketWrite?.()
|
||||
continue
|
||||
}
|
||||
const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES
|
||||
if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) {
|
||||
const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0
|
||||
if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) {
|
||||
// Hold this flooding session's entry; small talkers keep flowing. No timer: a deep socket implies a prior false write(), so 'drain' (routed back to flush) is guaranteed to resume held bulk.
|
||||
heldSessions.add(entry.sessionId)
|
||||
retained.push(entry)
|
||||
batch.queue.shift()
|
||||
continue
|
||||
}
|
||||
} else if (socketDeep) {
|
||||
// Valve engaged: held bulk exceeded the memory cap, so echo protection is off until it drains — rare enough to log every time.
|
||||
recordDaemonStreamBacklogEvent('heldWriteThrough', {
|
||||
heldChars: batch.queuedChars,
|
||||
socketBufferedBytes: socket.writableLength ?? 0
|
||||
})
|
||||
}
|
||||
const end =
|
||||
entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS
|
||||
? entry.data.length
|
||||
: clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS)
|
||||
const slice = entry.data.slice(0, end)
|
||||
const entrySequenceChars = entry.sequenceChars ?? entry.data.length
|
||||
const sliceSequenceChars = entry.transformed
|
||||
? entrySequenceChars
|
||||
: entrySequenceChars === 0
|
||||
? 0
|
||||
: slice.length
|
||||
if (end >= entry.data.length) {
|
||||
batch.queue.shift()
|
||||
} else {
|
||||
entry.data = entry.data.slice(end)
|
||||
const remainingSequenceChars = entrySequenceChars - sliceSequenceChars
|
||||
entry.sequenceChars =
|
||||
remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars
|
||||
}
|
||||
batch.queuedChars -= slice.length
|
||||
const sessionHeldAfter =
|
||||
(batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length
|
||||
if (sessionHeldAfter <= 0) {
|
||||
batch.queuedCharsBySession.delete(entry.sessionId)
|
||||
batch.droppableQueuedSessionIds.delete(entry.sessionId)
|
||||
} else {
|
||||
batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter)
|
||||
}
|
||||
writeStreamDataEvents(
|
||||
socket,
|
||||
entry.sessionId,
|
||||
slice,
|
||||
this.maxLineBytes,
|
||||
sliceSequenceChars,
|
||||
entry.seq,
|
||||
entry.transformed
|
||||
)
|
||||
this.onAfterSocketWrite?.()
|
||||
}
|
||||
if (retained.length > 0) {
|
||||
batch.queue = retained
|
||||
// 'drain' only fires when the buffer fully empties (one gate-depth/turn = seconds for multi-MB backlogs); arm a no-op data event whose flush callback re-flushes while bytes are still in flight.
|
||||
this.armHeldQueueRefill(socket, clientId, retained[0].sessionId)
|
||||
return
|
||||
}
|
||||
this.pendingByClient.delete(clientId)
|
||||
flushDaemonStreamData(this.flushArguments(), clientId)
|
||||
}
|
||||
|
||||
private refillArmedClients = new Set<string>()
|
||||
|
||||
private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void {
|
||||
if (this.refillArmedClients.has(clientId) || socket.destroyed) {
|
||||
return
|
||||
private flushArguments(): DaemonStreamDataFlushArguments {
|
||||
return {
|
||||
pendingByClient: this.pendingByClient,
|
||||
refillArmedClients: this.refillArmedClients,
|
||||
getClient: this.getClient,
|
||||
maxLineBytes: this.maxLineBytes,
|
||||
onAfterSocketWrite: this.onAfterSocketWrite
|
||||
}
|
||||
this.refillArmedClients.add(clientId)
|
||||
// Must be a real protocol no-op line, not an empty write: an empty write's callback fires immediately, defeating the in-flight re-flush.
|
||||
socket.write(encodeStreamDataEvent(sessionId, ''), () => {
|
||||
this.refillArmedClients.delete(clientId)
|
||||
this.flush(clientId)
|
||||
})
|
||||
}
|
||||
|
||||
private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number {
|
||||
let chars = 0
|
||||
for (const entry of batch.queue) {
|
||||
if (entry.sessionId === sessionId) {
|
||||
chars += entry.data.length
|
||||
}
|
||||
}
|
||||
return chars
|
||||
}
|
||||
|
||||
private flushSession(clientId: string, sessionId: string): void {
|
||||
@@ -265,55 +197,25 @@ export class DaemonStreamDataBatcher {
|
||||
return
|
||||
}
|
||||
|
||||
const flushed: PendingStreamDataBatch['queue'] = []
|
||||
const retained: PendingStreamDataBatch['queue'] = []
|
||||
let flushedChars = 0
|
||||
for (const entry of batch.queue) {
|
||||
if (entry.sessionId === sessionId) {
|
||||
flushed.push(entry)
|
||||
flushedChars += entry.data.length
|
||||
} else {
|
||||
retained.push(entry)
|
||||
}
|
||||
}
|
||||
if (flushed.length === 0) {
|
||||
const client = this.getClient(clientId)
|
||||
const result = flushDaemonStreamSession({
|
||||
batch,
|
||||
socket: client?.streamSocket && !client.streamSocket.destroyed ? client.streamSocket : null,
|
||||
sessionId,
|
||||
maxLineBytes: this.maxLineBytes,
|
||||
onAfterSocketWrite: this.onAfterSocketWrite,
|
||||
writeControl: writeDaemonStreamControl
|
||||
})
|
||||
if (!result.flushed) {
|
||||
return
|
||||
}
|
||||
|
||||
batch.queue = retained
|
||||
batch.queuedChars -= flushedChars
|
||||
batch.queuedCharsBySession.delete(sessionId)
|
||||
batch.droppableQueuedSessionIds.delete(sessionId)
|
||||
if (batch.queue.length === 0) {
|
||||
if (result.queueEmpty) {
|
||||
if (batch.timer) {
|
||||
clearTimeout(batch.timer)
|
||||
batch.timer = null
|
||||
}
|
||||
this.pendingByClient.delete(clientId)
|
||||
}
|
||||
|
||||
const client = this.getClient(clientId)
|
||||
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of flushed) {
|
||||
if (entry.control) {
|
||||
client.streamSocket.write(encodeNdjson(entry.control))
|
||||
this.onAfterSocketWrite?.()
|
||||
} else {
|
||||
writeStreamDataEvents(
|
||||
client.streamSocket,
|
||||
entry.sessionId,
|
||||
entry.data,
|
||||
this.maxLineBytes,
|
||||
entry.sequenceChars ?? entry.data.length,
|
||||
entry.seq,
|
||||
entry.transformed
|
||||
)
|
||||
this.onAfterSocketWrite?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(clientId?: string): void {
|
||||
@@ -326,7 +228,11 @@ export class DaemonStreamDataBatcher {
|
||||
if (batch?.timer) {
|
||||
clearTimeout(batch.timer)
|
||||
}
|
||||
for (const settle of this.pendingSettlementsByClient.get(id) ?? []) {
|
||||
settle(new Error('daemon stream queue was cleared'))
|
||||
}
|
||||
this.pendingByClient.delete(id)
|
||||
this.pendingSettlementsByClient.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import {
|
||||
clampToSafeSplitIndex,
|
||||
encodeStreamDataEvent,
|
||||
writeStreamDataEvents
|
||||
} from './daemon-stream-data-split'
|
||||
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
|
||||
const SHALLOW_SOCKET_WRITE_GATE_BYTES =
|
||||
process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024
|
||||
const BULK_WRITE_SLICE_CHARS = 64 * 1024
|
||||
const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024
|
||||
const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024
|
||||
|
||||
export type DaemonStreamDataFlushArguments = {
|
||||
pendingByClient: Map<string, PendingStreamDataBatch>
|
||||
refillArmedClients: Set<string>
|
||||
getClient: (clientId: string) => { streamSocket: Socket | null } | undefined
|
||||
maxLineBytes: number
|
||||
onAfterSocketWrite: (() => void) | undefined
|
||||
}
|
||||
|
||||
export function flushDaemonStreamData(
|
||||
args: DaemonStreamDataFlushArguments,
|
||||
clientId: string
|
||||
): void {
|
||||
const batch = args.pendingByClient.get(clientId)
|
||||
if (!batch) {
|
||||
return
|
||||
}
|
||||
|
||||
if (batch.timer) {
|
||||
clearTimeout(batch.timer)
|
||||
batch.timer = null
|
||||
}
|
||||
|
||||
const client = args.getClient(clientId)
|
||||
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
||||
args.pendingByClient.delete(clientId)
|
||||
return
|
||||
}
|
||||
|
||||
const socket = client.streamSocket
|
||||
const heldSessions = new Set<string>()
|
||||
const retained: PendingStreamDataBatch['queue'] = []
|
||||
while (batch.queue.length > 0) {
|
||||
const entry = batch.queue[0]
|
||||
if (entry.control) {
|
||||
if (heldSessions.has(entry.sessionId)) {
|
||||
retained.push(entry)
|
||||
batch.queue.shift()
|
||||
continue
|
||||
}
|
||||
batch.queue.shift()
|
||||
writeDaemonStreamControl(socket, entry)
|
||||
args.onAfterSocketWrite?.()
|
||||
continue
|
||||
}
|
||||
const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES
|
||||
if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) {
|
||||
const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0
|
||||
if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) {
|
||||
heldSessions.add(entry.sessionId)
|
||||
retained.push(entry)
|
||||
batch.queue.shift()
|
||||
continue
|
||||
}
|
||||
} else if (socketDeep) {
|
||||
recordDaemonStreamBacklogEvent('heldWriteThrough', {
|
||||
heldChars: batch.queuedChars,
|
||||
socketBufferedBytes: socket.writableLength ?? 0
|
||||
})
|
||||
}
|
||||
const end =
|
||||
entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS
|
||||
? entry.data.length
|
||||
: clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS)
|
||||
const slice = entry.data.slice(0, end)
|
||||
const entrySequenceChars = entry.sequenceChars ?? entry.data.length
|
||||
const sliceSequenceChars = entry.transformed
|
||||
? entrySequenceChars
|
||||
: entrySequenceChars === 0
|
||||
? 0
|
||||
: slice.length
|
||||
if (end >= entry.data.length) {
|
||||
batch.queue.shift()
|
||||
} else {
|
||||
entry.data = entry.data.slice(end)
|
||||
const remainingSequenceChars = entrySequenceChars - sliceSequenceChars
|
||||
entry.sequenceChars =
|
||||
remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars
|
||||
}
|
||||
batch.queuedChars -= slice.length
|
||||
const sessionHeldAfter =
|
||||
(batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length
|
||||
if (sessionHeldAfter <= 0) {
|
||||
batch.queuedCharsBySession.delete(entry.sessionId)
|
||||
batch.droppableQueuedSessionIds.delete(entry.sessionId)
|
||||
} else {
|
||||
batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter)
|
||||
}
|
||||
writeStreamDataEvents(
|
||||
socket,
|
||||
entry.sessionId,
|
||||
slice,
|
||||
args.maxLineBytes,
|
||||
sliceSequenceChars,
|
||||
entry.seq,
|
||||
entry.transformed
|
||||
)
|
||||
args.onAfterSocketWrite?.()
|
||||
}
|
||||
if (retained.length > 0) {
|
||||
batch.queue = retained
|
||||
armHeldDaemonStreamQueue(args, socket, clientId, retained[0].sessionId)
|
||||
return
|
||||
}
|
||||
args.pendingByClient.delete(clientId)
|
||||
}
|
||||
|
||||
function armHeldDaemonStreamQueue(
|
||||
args: DaemonStreamDataFlushArguments,
|
||||
socket: Socket,
|
||||
clientId: string,
|
||||
sessionId: string
|
||||
): void {
|
||||
if (args.refillArmedClients.has(clientId) || socket.destroyed) {
|
||||
return
|
||||
}
|
||||
args.refillArmedClients.add(clientId)
|
||||
socket.write(encodeStreamDataEvent(sessionId, ''), () => {
|
||||
args.refillArmedClients.delete(clientId)
|
||||
flushDaemonStreamData(args, clientId)
|
||||
})
|
||||
}
|
||||
|
||||
export function writeDaemonStreamControl(
|
||||
socket: Socket,
|
||||
entry: PendingStreamDataBatch['queue'][number]
|
||||
): void {
|
||||
try {
|
||||
socket.write(encodeNdjson(entry.control!), (error?: Error | null) => {
|
||||
entry.settle?.(error ?? null)
|
||||
})
|
||||
} catch (error) {
|
||||
entry.settle?.(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import type { BackgroundTransientFactRelay } from './daemon-background-transient
|
||||
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type { DaemonRequest } from './types'
|
||||
import {
|
||||
sameTerminalSessionAuthorityPtyAccess,
|
||||
type TerminalSessionAuthorityPtyAccess
|
||||
} from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
import { TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY } from './daemon-hello-protocol'
|
||||
|
||||
type MockSubprocess = SubprocessHandle & {
|
||||
emitData(data: string): void
|
||||
@@ -23,6 +29,15 @@ type DaemonLifecyclePrivate = {
|
||||
controlSocket: Socket
|
||||
streamSocket: Socket | null
|
||||
authenticatedPairEstablished: boolean
|
||||
capabilities?: {
|
||||
terminalSessionAuthority: 1
|
||||
terminalAuthorityConsumerProof?: { version: 1; authorityHostId: string }
|
||||
}
|
||||
authorityConsumerTransport?: { token: object }
|
||||
authorityNamespaceSessions?: Map<
|
||||
string,
|
||||
{ session: { policyConsumer: unknown; disconnect(): void }; acceptances: { close(): void } }
|
||||
>
|
||||
}
|
||||
>
|
||||
host: {
|
||||
@@ -42,6 +57,9 @@ function createMockSubprocess(): MockSubprocess {
|
||||
getForegroundProcess: () => null,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
@@ -61,7 +79,11 @@ function createMockSubprocess(): MockSubprocess {
|
||||
}
|
||||
}
|
||||
|
||||
function createServerHarness() {
|
||||
function createServerHarness(
|
||||
terminalSessionAuthority?: ConstructorParameters<
|
||||
typeof DaemonServer
|
||||
>[0]['terminalSessionAuthority']
|
||||
) {
|
||||
const subprocesses: MockSubprocess[] = []
|
||||
const unique = randomUUID()
|
||||
const server = new DaemonServer({
|
||||
@@ -71,7 +93,15 @@ function createServerHarness() {
|
||||
const subprocess = createMockSubprocess()
|
||||
subprocesses.push(subprocess)
|
||||
return subprocess
|
||||
}
|
||||
},
|
||||
...(terminalSessionAuthority
|
||||
? {
|
||||
terminalSessionAuthority,
|
||||
terminalSessionAuthorityCapabilityReadiness: {
|
||||
hostEffectConsumerInstalled: () => true
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return {
|
||||
server,
|
||||
@@ -82,7 +112,8 @@ function createServerHarness() {
|
||||
|
||||
function addClient(
|
||||
daemon: DaemonLifecyclePrivate,
|
||||
writableLength = 0
|
||||
writableLength = 0,
|
||||
authority = false
|
||||
): Socket & { write: ReturnType<typeof vi.fn>; writableLength: number } {
|
||||
const controlSocket = { destroy: vi.fn() } as unknown as Socket
|
||||
const streamSocket = {
|
||||
@@ -94,11 +125,41 @@ function addClient(
|
||||
write: ReturnType<typeof vi.fn>
|
||||
writableLength: number
|
||||
}
|
||||
const policyIdentity = {
|
||||
consumerId: 'app-profile:droppability-test',
|
||||
consumerIncarnationId: 'app-process:droppability-test'
|
||||
}
|
||||
// Authority admission is proof-only, so the client carries a negotiated proof grant and one
|
||||
// already-admitted namespace session rather than a self-named consumer.
|
||||
const policyConsumer = {
|
||||
identity: policyIdentity,
|
||||
assertInstalled: () => {},
|
||||
isInstalled: () => true,
|
||||
disconnect: () => {}
|
||||
}
|
||||
daemon.clients.set('client-1', {
|
||||
clientId: 'client-1',
|
||||
controlSocket,
|
||||
streamSocket,
|
||||
authenticatedPairEstablished: true
|
||||
authenticatedPairEstablished: true,
|
||||
...(authority
|
||||
? {
|
||||
capabilities: {
|
||||
terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY,
|
||||
terminalAuthorityConsumerProof: { version: 1 as const, authorityHostId: 'host-a' }
|
||||
},
|
||||
authorityConsumerTransport: { token: Object.freeze({}) },
|
||||
authorityNamespaceSessions: new Map([
|
||||
[
|
||||
JSON.stringify(['host-a', 'namespace-a']),
|
||||
{
|
||||
session: { policyConsumer, disconnect: () => {} },
|
||||
acceptances: { close: () => {} }
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return streamSocket
|
||||
}
|
||||
@@ -233,6 +294,86 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
expect(lifecycle).toEqual(['refresh:client-1', 'marker:sessionBackgroundMarker'])
|
||||
})
|
||||
|
||||
it('keeps legacy transient facts on the existing ordered stream', async () => {
|
||||
const harness = createServerHarness()
|
||||
server = harness.server
|
||||
const { daemon, subprocesses } = harness
|
||||
addClient(daemon)
|
||||
daemon.transientFactRelay.setSessionBackground('legacy-session', true)
|
||||
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
id: 'attach',
|
||||
type: 'createOrAttach',
|
||||
payload: { sessionId: 'legacy-session', cols: 80, rows: 24 }
|
||||
})
|
||||
subprocesses[0]!.emitData('ding\x07')
|
||||
|
||||
expect(enqueue).toHaveBeenCalledWith('client-1', 'legacy-session', {
|
||||
type: 'event',
|
||||
event: 'transientFact',
|
||||
sessionId: 'legacy-session',
|
||||
payload: { kind: 'bell' }
|
||||
})
|
||||
})
|
||||
|
||||
it('persists authoritative facts against the captured binding without legacy publication', async () => {
|
||||
let currentAccess: TerminalSessionAuthorityPtyAccess | null = null
|
||||
const recordSemanticOutcome = vi.fn(
|
||||
async (_sessionId: string, access: TerminalSessionAuthorityPtyAccess) =>
|
||||
sameTerminalSessionAuthorityPtyAccess(currentAccess, access)
|
||||
)
|
||||
const owner = {
|
||||
prepareSpawn: vi.fn(async () => ({ kind: 'spawn', prepared: {} })),
|
||||
commitSpawn: vi.fn(async (_prepared: unknown, incarnationId: string) => {
|
||||
currentAccess = authorityAccess(incarnationId)
|
||||
return currentAccess
|
||||
}),
|
||||
cancelSpawn: vi.fn(async () => undefined),
|
||||
accessFor: vi.fn(() => currentAccess),
|
||||
admits: vi.fn((_id: string, access: TerminalSessionAuthorityPtyAccess) =>
|
||||
sameTerminalSessionAuthorityPtyAccess(currentAccess, access)
|
||||
),
|
||||
recordSemanticOutcome,
|
||||
recordExit: vi.fn(async () => {
|
||||
currentAccess = null
|
||||
}),
|
||||
releaseAuthenticatedPolicyConsumerTransport: vi.fn(),
|
||||
close: vi.fn(async () => true),
|
||||
adopt: vi.fn()
|
||||
} as unknown as TerminalSessionAuthorityPtyOwner
|
||||
const harness = createServerHarness({ ptyOwner: owner })
|
||||
server = harness.server
|
||||
const { daemon, subprocesses } = harness
|
||||
addClient(daemon, 0, true)
|
||||
daemon.transientFactRelay.setSessionBackground('authority-session', true)
|
||||
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
|
||||
|
||||
const result = (await daemon.routeRequest('client-1', {
|
||||
id: 'attach',
|
||||
type: 'createOrAttach',
|
||||
payload: {
|
||||
sessionId: 'authority-session',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
terminalSessionAuthorityVersion: 1,
|
||||
terminalSessionAuthorityOperationId: 'spawn-a',
|
||||
worktreeId: 'repo::/srv/repo',
|
||||
paneKey: 'pane-a',
|
||||
paneGeneration: 1
|
||||
}
|
||||
})) as { terminalSessionAuthorityAccess: TerminalSessionAuthorityPtyAccess }
|
||||
subprocesses[0]!.emitData('ding\x07')
|
||||
|
||||
await vi.waitFor(() => expect(recordSemanticOutcome).toHaveBeenCalledOnce())
|
||||
expect(recordSemanticOutcome).toHaveBeenCalledWith(
|
||||
'authority-session',
|
||||
result.terminalSessionAuthorityAccess,
|
||||
{ kind: 'bell' }
|
||||
)
|
||||
expect(enqueue.mock.calls.some(([, , event]) => event.event === 'transientFact')).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates droppable membership for final output held behind a deep socket', async () => {
|
||||
const harness = createServerHarness()
|
||||
server = harness.server
|
||||
@@ -264,3 +405,15 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function authorityAccess(ptyIncarnationId: string): TerminalSessionAuthorityPtyAccess {
|
||||
return {
|
||||
namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' },
|
||||
pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:1' },
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-a',
|
||||
physicalPtyId: 'authority-session',
|
||||
ptyIncarnationId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// ─── Events (Daemon → Client, on stream socket) ────────────────────
|
||||
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type {
|
||||
TerminalAuthorityNamespaceOutcomeBoundary,
|
||||
TerminalAuthorityNamespaceOutcomePublication
|
||||
} from '../../shared/terminal-session-authority-consumer-transport'
|
||||
|
||||
export type DataEvent = {
|
||||
type: 'event'
|
||||
@@ -16,6 +20,16 @@ export type DataEvent = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionSourceEvent = {
|
||||
type: 'event'
|
||||
event: 'sessionSource'
|
||||
sessionId: string
|
||||
payload: {
|
||||
incarnationId: PtyIncarnationId
|
||||
streamBindingNonce: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ExitEvent = {
|
||||
type: 'event'
|
||||
event: 'exit'
|
||||
@@ -83,10 +97,27 @@ export type TransientFactEvent = {
|
||||
payload: DaemonTransientFact
|
||||
}
|
||||
|
||||
export type TerminalAuthorityNamespaceOutcomeBoundaryEvent = {
|
||||
type: 'event'
|
||||
event: 'terminalAuthorityNamespaceOutcomeBoundary'
|
||||
sessionId: string
|
||||
payload: TerminalAuthorityNamespaceOutcomeBoundary
|
||||
}
|
||||
|
||||
export type TerminalAuthorityNamespaceOutcomeEvent = {
|
||||
type: 'event'
|
||||
event: 'terminalAuthorityNamespaceOutcome'
|
||||
sessionId: string
|
||||
payload: TerminalAuthorityNamespaceOutcomePublication
|
||||
}
|
||||
|
||||
export type DaemonEvent =
|
||||
| DataEvent
|
||||
| SessionSourceEvent
|
||||
| ExitEvent
|
||||
| TerminalErrorEvent
|
||||
| SessionBackgroundMarkerEvent
|
||||
| DataGapEvent
|
||||
| TransientFactEvent
|
||||
| TerminalAuthorityNamespaceOutcomeBoundaryEvent
|
||||
| TerminalAuthorityNamespaceOutcomeEvent
|
||||
|
||||
@@ -24,6 +24,7 @@ export type StreamQueueEntry = {
|
||||
seq?: number
|
||||
transformed?: boolean
|
||||
control?: DaemonEvent
|
||||
settle?: (error: Error | null) => void
|
||||
}
|
||||
|
||||
export type PendingStreamDataBatch = {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { writeStreamDataEvents } from './daemon-stream-data-split'
|
||||
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
|
||||
export function flushDaemonStreamSession(args: {
|
||||
batch: PendingStreamDataBatch
|
||||
socket: Socket | null
|
||||
sessionId: string
|
||||
maxLineBytes: number
|
||||
onAfterSocketWrite?: () => void
|
||||
writeControl: (socket: Socket, entry: PendingStreamDataBatch['queue'][number]) => void
|
||||
}): { flushed: boolean; queueEmpty: boolean } {
|
||||
const flushed: PendingStreamDataBatch['queue'] = []
|
||||
const retained: PendingStreamDataBatch['queue'] = []
|
||||
let flushedChars = 0
|
||||
for (const entry of args.batch.queue) {
|
||||
if (entry.sessionId === args.sessionId) {
|
||||
flushed.push(entry)
|
||||
flushedChars += entry.data.length
|
||||
} else {
|
||||
retained.push(entry)
|
||||
}
|
||||
}
|
||||
if (flushed.length === 0) {
|
||||
return { flushed: false, queueEmpty: false }
|
||||
}
|
||||
|
||||
args.batch.queue = retained
|
||||
args.batch.queuedChars -= flushedChars
|
||||
args.batch.queuedCharsBySession.delete(args.sessionId)
|
||||
args.batch.droppableQueuedSessionIds.delete(args.sessionId)
|
||||
for (const entry of flushed) {
|
||||
if (args.socket) {
|
||||
if (entry.control) {
|
||||
args.writeControl(args.socket, entry)
|
||||
} else {
|
||||
writeStreamDataEvents(
|
||||
args.socket,
|
||||
entry.sessionId,
|
||||
entry.data,
|
||||
args.maxLineBytes,
|
||||
entry.sequenceChars ?? entry.data.length,
|
||||
entry.seq,
|
||||
entry.transformed
|
||||
)
|
||||
}
|
||||
args.onAfterSocketWrite?.()
|
||||
}
|
||||
}
|
||||
return { flushed: true, queueEmpty: args.batch.queue.length === 0 }
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalAuthorityNamespaceOutcomeBoundary } from '../../shared/terminal-session-authority-consumer-transport'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { DaemonTerminalAuthorityAppHostTransport } from './daemon-terminal-authority-app-host-transport'
|
||||
import {
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST
|
||||
} from './daemon-terminal-authority-consumer-requests'
|
||||
import { TerminalAuthorityAppOutcomeHostManager } from '../session-authority/terminal-authority-app-outcome-host-manager'
|
||||
import {
|
||||
TerminalAuthorityAppOutcomeHostTransportSlot,
|
||||
type TerminalAuthorityAppOutcomeHostTransportLease
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-transport-slot'
|
||||
import type { TerminalAuthorityAppOutcomeNamespaceConnection } from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store'
|
||||
import {
|
||||
createTerminalAuthorityProofEphemeralKeypair,
|
||||
terminalAuthorityHostAppConsumerId
|
||||
} from '../session-authority/terminal-session-authority-consumer-proof'
|
||||
import { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
import { TerminalSessionAuthorityRegistry } from '../session-authority/terminal-session-authority-registry'
|
||||
import type { TerminalSessionAuthorityService } from '../session-authority/terminal-session-authority-service'
|
||||
import { terminalAuthorityWorkspaceLocator } from '../session-authority/terminal-session-authority-workspace-locator'
|
||||
|
||||
const resources: AuthorityHarness[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const harness of resources.splice(0)) {
|
||||
harness.manager?.dispose()
|
||||
harness.transportLease?.dispose()
|
||||
harness.store?.close()
|
||||
harness.client?.disconnect()
|
||||
harness.guardian.disconnect()
|
||||
await harness.server.shutdown()
|
||||
harness.owner.dispose()
|
||||
await harness.registry.close()
|
||||
rmSync(harness.directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('daemon authenticated terminal authority admission', () => {
|
||||
it('accepts the first boundary before grant and resumes the retained cursor after app restart', async () => {
|
||||
const harness = await startHarness()
|
||||
const keypair = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const consumerId = terminalAuthorityHostAppConsumerId(
|
||||
harness.service.namespace.authorityHostId,
|
||||
keypair.publicKey
|
||||
)
|
||||
await connectApp(harness, keypair, 'app-process:first')
|
||||
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:first')
|
||||
expect(await consumerSnapshot(harness.service, consumerId, 'app-process:first')).toMatchObject({
|
||||
acknowledgedSequence: 0,
|
||||
outcomeHighWatermark: 0
|
||||
})
|
||||
|
||||
harness.manager?.dispose()
|
||||
harness.manager = null
|
||||
harness.client?.disconnect()
|
||||
harness.client = null
|
||||
harness.store?.close()
|
||||
harness.store = null
|
||||
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:first')
|
||||
await connectApp(harness, keypair, 'app-process:second')
|
||||
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:second')
|
||||
expect(await consumerSnapshot(harness.service, consumerId, 'app-process:second')).toMatchObject(
|
||||
{ acknowledgedSequence: 0, outcomeHighWatermark: 0 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls back a first claim when the app disconnects during boundary acceptance', async () => {
|
||||
const harness = await startHarness()
|
||||
const keypair = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const consumerId = terminalAuthorityHostAppConsumerId(
|
||||
harness.service.namespace.authorityHostId,
|
||||
keypair.publicKey
|
||||
)
|
||||
const client = new DaemonClient({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
harness.client = client
|
||||
const host = await new DaemonTerminalAuthorityAppHostTransport({
|
||||
client,
|
||||
authenticatedAuthorityHostId: harness.service.namespace.authorityHostId,
|
||||
keypair
|
||||
}).connect({ onFailure: () => {} })
|
||||
const boundaryStarted = deferred<TerminalAuthorityNamespaceOutcomeBoundary>()
|
||||
const releaseBoundary = deferred<void>()
|
||||
let provisional: TerminalAuthorityAppOutcomeNamespaceConnection | null = null
|
||||
const opening = host.openNamespace(
|
||||
{
|
||||
namespace: harness.service.namespace,
|
||||
candidateProcessIncarnationId: 'app-process:disconnecting',
|
||||
candidateSessionNonce: 'app-session:disconnecting',
|
||||
requestId: 'app-request:disconnecting',
|
||||
intent: 'first'
|
||||
},
|
||||
{
|
||||
publishBoundary: async (boundary) => {
|
||||
boundaryStarted.resolve(boundary)
|
||||
await releaseBoundary.promise
|
||||
await provisional?.acceptBoundary(boundaryAcceptance(boundary))
|
||||
},
|
||||
publishOutcome: async () => {}
|
||||
},
|
||||
(connection) => {
|
||||
provisional = connection
|
||||
}
|
||||
)
|
||||
await within(boundaryStarted.promise, 3_000, () => 'boundary was not published')
|
||||
|
||||
client.disconnect()
|
||||
releaseBoundary.resolve()
|
||||
|
||||
await expect(within(opening, 3_000, () => 'opening did not settle')).rejects.toThrow()
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the handover claim durable when the app disconnects mid-claim', async () => {
|
||||
const harness = await startHarness()
|
||||
const keypair = createTerminalAuthorityProofEphemeralKeypair()
|
||||
await connectApp(harness, keypair, 'app-process:incumbent')
|
||||
const consumerId = terminalAuthorityHostAppConsumerId(
|
||||
harness.service.namespace.authorityHostId,
|
||||
keypair.publicKey
|
||||
)
|
||||
const candidateClient = new DaemonClient({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
const candidateStore = await TerminalAuthorityAppProjectionStore.open({
|
||||
directory: path.join(harness.directory, 'candidate-projection')
|
||||
})
|
||||
const candidateManager = new TerminalAuthorityAppOutcomeHostManager('app-process:candidate', {
|
||||
store: candidateStore,
|
||||
onProjection: () => {},
|
||||
onError: () => {},
|
||||
connectTimeoutMs: 1_000,
|
||||
acknowledgeTimeoutMs: 1_000
|
||||
})
|
||||
const candidateSlot = new TerminalAuthorityAppOutcomeHostTransportSlot(
|
||||
harness.service.namespace.authorityHostId
|
||||
)
|
||||
const candidateTransportLease = candidateSlot.install(
|
||||
new DaemonTerminalAuthorityAppHostTransport({
|
||||
client: candidateClient,
|
||||
authenticatedAuthorityHostId: harness.service.namespace.authorityHostId,
|
||||
keypair
|
||||
})
|
||||
)
|
||||
const candidateRegistration = candidateManager.installHost(candidateSlot)
|
||||
const commitConsumerAdmission = harness.service.commitConsumerAdmission.bind(harness.service)
|
||||
const claimReturned = deferred<void>()
|
||||
const releaseClaim = deferred<void>()
|
||||
const interleaving: string[] = []
|
||||
vi.spyOn(harness.service, 'commitConsumerAdmission').mockImplementation(
|
||||
async (writer, input, seal) => {
|
||||
const consumer = await commitConsumerAdmission(writer, input, seal)
|
||||
if (input.consumerIncarnationId === 'app-process:candidate') {
|
||||
interleaving.push('claim-returned')
|
||||
claimReturned.resolve()
|
||||
await releaseClaim.promise
|
||||
interleaving.push('claim-released')
|
||||
}
|
||||
return consumer
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const opening = candidateRegistration.resolveAndAdmitNamespace(harness.worktreeId)
|
||||
await within(claimReturned.promise, 3_000, () => 'candidate claim was not returned')
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:candidate')
|
||||
|
||||
const disconnected = deferred<void>()
|
||||
const candidateClientId = (candidateClient as unknown as { clientId: string }).clientId
|
||||
const releaseAuthenticatedPolicyTransport = (
|
||||
harness.server as unknown as {
|
||||
releaseAuthenticatedPolicyTransport(client: { clientId: string }): void
|
||||
}
|
||||
).releaseAuthenticatedPolicyTransport.bind(harness.server)
|
||||
const releaseSpy = vi
|
||||
.spyOn(
|
||||
harness.server as unknown as {
|
||||
releaseAuthenticatedPolicyTransport(client: { clientId: string }): void
|
||||
},
|
||||
'releaseAuthenticatedPolicyTransport'
|
||||
)
|
||||
.mockImplementation((client: { clientId: string }) => {
|
||||
if (client.clientId === candidateClientId) {
|
||||
interleaving.push('transport-released')
|
||||
disconnected.resolve()
|
||||
}
|
||||
releaseAuthenticatedPolicyTransport(client)
|
||||
})
|
||||
candidateClient.disconnect()
|
||||
await within(disconnected.promise, 3_000, () => 'candidate disconnect was not observed')
|
||||
expect(interleaving).toEqual(['claim-returned', 'transport-released'])
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:candidate')
|
||||
|
||||
releaseClaim.resolve()
|
||||
await expect(
|
||||
within(opening, 3_000, () => 'candidate admission did not settle')
|
||||
).rejects.toThrow()
|
||||
// The claim and its grant settled together, so the disconnect releases live state and leaves
|
||||
// the durable record on the candidate for a fresh proof to resume.
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:candidate')
|
||||
expect(interleaving.slice(0, 3)).toEqual([
|
||||
'claim-returned',
|
||||
'transport-released',
|
||||
'claim-released'
|
||||
])
|
||||
releaseSpy.mockRestore()
|
||||
} finally {
|
||||
releaseClaim.resolve()
|
||||
candidateManager.dispose()
|
||||
candidateTransportLease.dispose()
|
||||
candidateStore.close()
|
||||
candidateClient.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
it('durably retires one authenticated namespace and replays its exact acknowledgement', async () => {
|
||||
const harness = await startHarness()
|
||||
const keypair = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const consumerId = terminalAuthorityHostAppConsumerId(
|
||||
harness.service.namespace.authorityHostId,
|
||||
keypair.publicKey
|
||||
)
|
||||
const registration = await connectApp(harness, keypair, 'app-process:retiring')
|
||||
const request = Object.freeze({
|
||||
namespace: harness.service.namespace,
|
||||
candidateProcessIncarnationId: 'app-process:retiring',
|
||||
candidateSessionNonce: 'app-session:retiring',
|
||||
requestId: 'app-retirement:daemon-integration'
|
||||
})
|
||||
|
||||
expect(harness.client?.terminalSessionAuthorityConsumerRetirementSupported()).toBe(true)
|
||||
await expect(registration.retireNamespace(request)).resolves.toMatchObject({
|
||||
consumerId,
|
||||
retired: true,
|
||||
alreadyAbsent: false,
|
||||
replayed: false
|
||||
})
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBeNull()
|
||||
await expect(registration.retireNamespace(request)).resolves.toMatchObject({
|
||||
consumerId,
|
||||
retired: true,
|
||||
alreadyAbsent: false,
|
||||
replayed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('retries after a lost durable response without re-claiming on daemon reconnect', async () => {
|
||||
const harness = await startHarness()
|
||||
const keypair = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const consumerId = terminalAuthorityHostAppConsumerId(
|
||||
harness.service.namespace.authorityHostId,
|
||||
keypair.publicKey
|
||||
)
|
||||
const registration = await connectApp(harness, keypair, 'app-process:lost-retirement')
|
||||
const client = harness.client!
|
||||
const originalRequest = client.request.bind(client)
|
||||
const requestTypes: string[] = []
|
||||
let loseResponse = true
|
||||
vi.spyOn(client, 'request').mockImplementation(async (type, payload, timeoutMs) => {
|
||||
const result = await originalRequest(type, payload, timeoutMs)
|
||||
requestTypes.push(type)
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST && loseResponse) {
|
||||
loseResponse = false
|
||||
const socket = (
|
||||
client as unknown as { controlSocket: Readonly<{ destroy(): void }> | null }
|
||||
).controlSocket
|
||||
socket?.destroy()
|
||||
throw new Error('daemon retirement response lost after durable append')
|
||||
}
|
||||
return result
|
||||
})
|
||||
const request = Object.freeze({
|
||||
namespace: harness.service.namespace,
|
||||
candidateProcessIncarnationId: 'app-process:lost-retirement',
|
||||
candidateSessionNonce: 'app-session:lost-retirement',
|
||||
requestId: 'app-retirement:lost-daemon-response'
|
||||
})
|
||||
|
||||
await expect(registration.retireNamespace(request)).rejects.toThrow('response lost')
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBeNull()
|
||||
await vi.waitFor(() => expect(client.isConnected()).toBe(false))
|
||||
await expect(registration.retireNamespace(request)).resolves.toMatchObject({
|
||||
consumerId,
|
||||
retired: true,
|
||||
alreadyAbsent: true,
|
||||
replayed: false
|
||||
})
|
||||
expect(activeIncarnation(harness.service, consumerId)).toBeNull()
|
||||
expect(requestTypes).not.toContain(DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST)
|
||||
})
|
||||
})
|
||||
|
||||
type AuthorityHarness = {
|
||||
directory: string
|
||||
worktreeId: string
|
||||
registry: TerminalSessionAuthorityRegistry
|
||||
owner: TerminalSessionAuthorityPtyOwner
|
||||
service: TerminalSessionAuthorityService
|
||||
server: DaemonServer
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
guardian: DaemonClient
|
||||
client: DaemonClient | null
|
||||
manager: TerminalAuthorityAppOutcomeHostManager | null
|
||||
store: TerminalAuthorityAppProjectionStore | null
|
||||
transportLease: TerminalAuthorityAppOutcomeHostTransportLease | null
|
||||
}
|
||||
|
||||
async function startHarness(): Promise<AuthorityHarness> {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'daemon-authority-admission-'))
|
||||
const authorityHostId = 'authority-host:daemon-admission-integration'
|
||||
const registry = await TerminalSessionAuthorityRegistry.open({
|
||||
directory: path.join(directory, 'authority'),
|
||||
authorityHostId,
|
||||
ownerToken: 'owner-token:daemon-admission-integration',
|
||||
ownerIncarnationId: 'owner-incarnation:daemon-admission-integration',
|
||||
writerActorId: 'writer:daemon-admission-integration'
|
||||
})
|
||||
const namespace = (await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory)))
|
||||
.namespace
|
||||
const service = await registry.openNamespace(namespace)
|
||||
const owner = new TerminalSessionAuthorityPtyOwner({
|
||||
registry,
|
||||
ownerIncarnationId: 'owner-incarnation:daemon-admission-integration'
|
||||
})
|
||||
const socketPath = getDaemonSocketPath(directory)
|
||||
const tokenPath = path.join(directory, 'daemon.token')
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('unexpected subprocess spawn')
|
||||
},
|
||||
terminalSessionAuthority: { authorityHostId, ptyOwner: owner },
|
||||
terminalSessionAuthorityCapabilityReadiness: {
|
||||
hostEffectConsumerInstalled: () => owner.hostEffectConsumerInstalled()
|
||||
}
|
||||
})
|
||||
await owner.start()
|
||||
await server.start()
|
||||
const guardian = new DaemonClient({ socketPath, tokenPath })
|
||||
await guardian.ensureConnected()
|
||||
const harness: AuthorityHarness = {
|
||||
directory,
|
||||
worktreeId: `repo::${directory}`,
|
||||
registry,
|
||||
owner,
|
||||
service,
|
||||
server,
|
||||
socketPath,
|
||||
tokenPath,
|
||||
guardian,
|
||||
client: null,
|
||||
manager: null,
|
||||
store: null,
|
||||
transportLease: null
|
||||
}
|
||||
resources.push(harness)
|
||||
return harness
|
||||
}
|
||||
|
||||
async function connectApp(
|
||||
harness: AuthorityHarness,
|
||||
keypair: ReturnType<typeof createTerminalAuthorityProofEphemeralKeypair>,
|
||||
processIncarnationId: string
|
||||
) {
|
||||
const client = new DaemonClient({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
const store = await TerminalAuthorityAppProjectionStore.open({
|
||||
directory: path.join(harness.directory, 'projection')
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const manager = new TerminalAuthorityAppOutcomeHostManager(processIncarnationId, {
|
||||
store,
|
||||
onProjection: () => {},
|
||||
onError: (error) => errors.push(error),
|
||||
connectTimeoutMs: 1_000,
|
||||
acknowledgeTimeoutMs: 1_000
|
||||
})
|
||||
harness.transportLease?.dispose()
|
||||
const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(
|
||||
harness.service.namespace.authorityHostId
|
||||
)
|
||||
const transportLease = slot.install(
|
||||
new DaemonTerminalAuthorityAppHostTransport({
|
||||
client,
|
||||
authenticatedAuthorityHostId: harness.service.namespace.authorityHostId,
|
||||
keypair
|
||||
})
|
||||
)
|
||||
const registration = manager.installHost(slot)
|
||||
harness.client = client
|
||||
harness.store = store
|
||||
harness.manager = manager
|
||||
harness.transportLease = transportLease
|
||||
await within(
|
||||
registration.resolveAndAdmitNamespace(harness.worktreeId),
|
||||
3_000,
|
||||
() => errors.map((error) => error.message).join(' | ') || 'no app outcome error'
|
||||
)
|
||||
return registration
|
||||
}
|
||||
|
||||
function activeIncarnation(
|
||||
service: TerminalSessionAuthorityService,
|
||||
consumerId: string
|
||||
): string | null {
|
||||
return service.activeConsumerIncarnation(service.writerAccess, consumerId)
|
||||
}
|
||||
|
||||
function consumerSnapshot(
|
||||
service: TerminalSessionAuthorityService,
|
||||
consumerId: string,
|
||||
consumerIncarnationId: string
|
||||
) {
|
||||
return service.snapshotForConsumer({
|
||||
role: 'consumer',
|
||||
serviceInstanceId: service.writerAccess.serviceInstanceId,
|
||||
consumerId,
|
||||
consumerIncarnationId
|
||||
})
|
||||
}
|
||||
|
||||
function within<T>(pending: Promise<T>, timeoutMs: number, detail: () => string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`daemon authority admission timed out: ${detail()}`)),
|
||||
timeoutMs
|
||||
)
|
||||
void pending.then(
|
||||
(value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function boundaryAcceptance(boundary: TerminalAuthorityNamespaceOutcomeBoundary) {
|
||||
if (!boundary.boundaryId) {
|
||||
throw new Error('daemon authority boundary identity is unavailable')
|
||||
}
|
||||
return {
|
||||
version: boundary.version,
|
||||
consumer: boundary.consumer,
|
||||
namespace: boundary.namespace,
|
||||
boundaryId: boundary.boundaryId,
|
||||
acknowledgedSequence: boundary.acknowledgedSequence,
|
||||
outcomeHighWatermark: boundary.outcomeHighWatermark
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM,
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
type TerminalAuthorityNamespaceAdmissionChallenge,
|
||||
type TerminalAuthorityNamespaceAdmissionGrant,
|
||||
type TerminalAuthorityNamespaceAdmissionProof,
|
||||
type TerminalAuthorityNamespaceAdmissionStart
|
||||
} from '../../shared/terminal-session-authority-consumer-proof'
|
||||
import {
|
||||
TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM,
|
||||
TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION,
|
||||
type TerminalAuthorityConsumerRetirementProof
|
||||
} from '../../shared/terminal-session-authority-consumer-retirement'
|
||||
import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity'
|
||||
import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from '../../shared/terminal-session-authority-consumer-transport'
|
||||
import {
|
||||
TerminalAuthorityAppAdmissionRejectedError,
|
||||
type TerminalAuthorityAppAdmissionIntentRequiredError
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import { TerminalAuthorityAppOutcomeHostTransportSlot } from '../session-authority/terminal-authority-app-outcome-host-transport-slot'
|
||||
import {
|
||||
createTerminalAuthorityProofEphemeralKeypair,
|
||||
terminalAuthorityAdmissionCas,
|
||||
terminalAuthorityHostAppConsumerId,
|
||||
terminalAuthorityRetirementCas
|
||||
} from '../session-authority/terminal-session-authority-consumer-proof'
|
||||
import {
|
||||
DaemonTerminalAuthorityAppHostTransport,
|
||||
type DaemonTerminalAuthorityAppClient
|
||||
} from './daemon-terminal-authority-app-host-transport'
|
||||
import {
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST
|
||||
} from './daemon-terminal-authority-consumer-requests'
|
||||
|
||||
const HOST_ID = 'authority-host:daemon-test'
|
||||
const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair()
|
||||
|
||||
describe('DaemonTerminalAuthorityAppHostTransport', () => {
|
||||
it('keeps proof construction internal and maps the host-required intent', async () => {
|
||||
const attempt = admission()
|
||||
const client = new FakeClient(async (type, payload) => {
|
||||
expect(type).toBe(DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST)
|
||||
expect(payload).toEqual(attempt.start)
|
||||
throw new Error('terminal authority namespace admission requires first')
|
||||
})
|
||||
const host = await connect(client)
|
||||
|
||||
await expect(host.openNamespace(attempt.request, outcomeTransport())).rejects.toMatchObject({
|
||||
requiredIntent: 'first'
|
||||
} satisfies Partial<TerminalAuthorityAppAdmissionIntentRequiredError>)
|
||||
expect(client.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('publishes the pre-grant boundary needed to unlock exact admission', async () => {
|
||||
const attempt = admission()
|
||||
const boundary = namespaceBoundary(attempt.grant)
|
||||
const publishBoundary = vi.fn(async () => {})
|
||||
let client!: FakeClient
|
||||
client = new FakeClient(async (type, payload) => {
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) {
|
||||
expect(payload).toEqual(attempt.start)
|
||||
return attempt.challenge
|
||||
}
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) {
|
||||
assertProofUsesChallenge(payload, attempt.challenge)
|
||||
client.emit('terminalAuthorityNamespaceOutcomeBoundary', boundary)
|
||||
return attempt.grant
|
||||
}
|
||||
throw new Error(`unexpected request: ${type}`)
|
||||
})
|
||||
const host = await connect(client)
|
||||
|
||||
const namespace = await host.openNamespace(attempt.request, {
|
||||
publishBoundary,
|
||||
publishOutcome: async () => {},
|
||||
onFailure: vi.fn()
|
||||
})
|
||||
|
||||
expect(namespace.grant).toEqual(attempt.grant)
|
||||
expect(publishBoundary).toHaveBeenCalledWith(boundary)
|
||||
await namespace.activate?.()
|
||||
expect(publishBoundary).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves the final-host namespace only over the authenticated proof transport', async () => {
|
||||
const expected = { authorityHostId: HOST_ID, namespaceId: 'namespace:resolved' }
|
||||
const client = new FakeClient(async (type, payload) => {
|
||||
expect(type).toBe(DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST)
|
||||
expect(payload).toEqual({ worktreeId: 'repo::/workspace' })
|
||||
return expected
|
||||
})
|
||||
const host = await connect(client)
|
||||
|
||||
await expect(host.resolveNamespace('repo::/workspace')).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('distinguishes definitive admission rejection from an uncertain response', async () => {
|
||||
const attempt = admission()
|
||||
const rejectedClient = new FakeClient(async () => {
|
||||
throw new Error('terminal authority namespace admission challenge expired')
|
||||
})
|
||||
const uncertainClient = new FakeClient(async () => {
|
||||
throw new Error('connection closed before response')
|
||||
})
|
||||
|
||||
await expect(
|
||||
(await connect(rejectedClient)).openNamespace(attempt.request, outcomeTransport())
|
||||
).rejects.toBeInstanceOf(TerminalAuthorityAppAdmissionRejectedError)
|
||||
await expect(
|
||||
(await connect(uncertainClient)).openNamespace(attempt.request, outcomeTransport())
|
||||
).rejects.not.toBeInstanceOf(TerminalAuthorityAppAdmissionRejectedError)
|
||||
})
|
||||
|
||||
it('retries an uncertain grant with the exact proof and no synthetic cancellation', async () => {
|
||||
const attempt = admission()
|
||||
const grantPayloads: unknown[] = []
|
||||
let grants = 0
|
||||
const client = new FakeClient(async (type, payload) => {
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) {
|
||||
return attempt.challenge
|
||||
}
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) {
|
||||
grantPayloads.push(payload)
|
||||
grants += 1
|
||||
if (grants === 1) {
|
||||
throw new Error('grant response timed out')
|
||||
}
|
||||
return { ...attempt.grant, replayed: true }
|
||||
}
|
||||
throw new Error(`unexpected request: ${type}`)
|
||||
})
|
||||
const host = await connect(client)
|
||||
|
||||
await expect(host.openNamespace(attempt.request, outcomeTransport())).rejects.toThrow(
|
||||
'grant response timed out'
|
||||
)
|
||||
const namespace = await host.openNamespace(attempt.request, outcomeTransport())
|
||||
|
||||
expect(namespace.grant.replayed).toBe(true)
|
||||
expect(grantPayloads[0]).toEqual(grantPayloads[1])
|
||||
expect(
|
||||
client.requests.some(
|
||||
(request) => request.type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('fences one namespace failure without advancing or failing its sibling', async () => {
|
||||
const first = admission('namespace:first')
|
||||
const second = admission('namespace:second')
|
||||
const attempts = new Map<string, ReturnType<typeof admission>>([
|
||||
[first.request.requestId, first],
|
||||
[second.request.requestId, second]
|
||||
])
|
||||
const client = new FakeClient(async (type, payload) => {
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) {
|
||||
return attempts.get((payload as TerminalAuthorityNamespaceAdmissionStart).requestId)
|
||||
?.challenge
|
||||
}
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) {
|
||||
return attempts.get(
|
||||
(payload as TerminalAuthorityNamespaceAdmissionProof).challenge.requestId
|
||||
)?.grant
|
||||
}
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST) {
|
||||
return { canceled: true }
|
||||
}
|
||||
throw new Error(`unexpected request: ${type}`)
|
||||
})
|
||||
const hostFailure = vi.fn()
|
||||
const host = await new DaemonTerminalAuthorityAppHostTransport({
|
||||
client,
|
||||
authenticatedAuthorityHostId: HOST_ID,
|
||||
keypair: APP_KEYPAIR
|
||||
}).connect({ onFailure: hostFailure })
|
||||
const firstFailure = vi.fn()
|
||||
const secondBoundary = vi.fn(async () => {})
|
||||
const firstConnection = await host.openNamespace(first.request, outcomeTransport(firstFailure))
|
||||
const secondConnection = await host.openNamespace(second.request, {
|
||||
publishBoundary: secondBoundary,
|
||||
publishOutcome: async () => {},
|
||||
onFailure: vi.fn()
|
||||
})
|
||||
await firstConnection.activate?.()
|
||||
await secondConnection.activate?.()
|
||||
|
||||
client.emit(
|
||||
'terminalAuthorityNamespaceOutcomeBoundary',
|
||||
namespaceBoundary({
|
||||
...first.grant,
|
||||
consumer: { ...first.grant.consumer, consumerIncarnationId: 'app-process:wrong' }
|
||||
})
|
||||
)
|
||||
client.emit('terminalAuthorityNamespaceOutcomeBoundary', namespaceBoundary(second.grant))
|
||||
await vi.waitFor(() => expect(secondBoundary).toHaveBeenCalledOnce())
|
||||
|
||||
expect(firstFailure).toHaveBeenCalledOnce()
|
||||
expect(hostFailure).not.toHaveBeenCalled()
|
||||
expect(secondConnection.grant).toEqual(second.grant)
|
||||
})
|
||||
|
||||
it('retries a lost retirement acknowledgement with the exact proof', async () => {
|
||||
const attempt = retirement()
|
||||
const proofs: unknown[] = []
|
||||
let completions = 0
|
||||
const client = new FakeClient(async (type, payload) => {
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST) {
|
||||
expect(payload).toEqual(attempt.start)
|
||||
return attempt.challenge
|
||||
}
|
||||
if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST) {
|
||||
proofs.push(payload)
|
||||
completions += 1
|
||||
if (completions === 1) {
|
||||
throw new Error('retirement response timed out')
|
||||
}
|
||||
return { ...attempt.result, replayed: true }
|
||||
}
|
||||
throw new Error(`unexpected request: ${type}`)
|
||||
})
|
||||
const host = await connect(client)
|
||||
|
||||
await expect(host.retireNamespace(attempt.request)).rejects.toThrow(
|
||||
'retirement response timed out'
|
||||
)
|
||||
await expect(host.retireNamespace(attempt.request)).resolves.toEqual({
|
||||
...attempt.result,
|
||||
replayed: true
|
||||
})
|
||||
expect(proofs).toHaveLength(2)
|
||||
expect(proofs[0]).toEqual(proofs[1])
|
||||
expect((proofs[0] as TerminalAuthorityConsumerRetirementProof).challenge).toEqual(
|
||||
attempt.challenge
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects retirement before mutation when the daemon did not grant support', async () => {
|
||||
const attempt = retirement()
|
||||
const client = new FakeClient(async () => {
|
||||
throw new Error('unexpected request')
|
||||
}, false)
|
||||
const host = await connect(client)
|
||||
|
||||
await expect(host.retireNamespace(attempt.request)).rejects.toThrow('unsupported')
|
||||
expect(client.requests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
class FakeClient implements DaemonTerminalAuthorityAppClient {
|
||||
readonly requests: { type: string; payload: unknown }[] = []
|
||||
private readonly eventListeners = new Set<(event: unknown) => void>()
|
||||
private readonly disconnectedListeners = new Set<() => void>()
|
||||
|
||||
constructor(
|
||||
private readonly handleRequest: (type: string, payload: unknown) => Promise<unknown>,
|
||||
private readonly retirementSupported = true
|
||||
) {}
|
||||
|
||||
async ensureConnected(): Promise<void> {}
|
||||
|
||||
terminalSessionAuthorityConsumerProofHostId(): string {
|
||||
return HOST_ID
|
||||
}
|
||||
|
||||
terminalSessionAuthorityConsumerRetirementSupported(): boolean {
|
||||
return this.retirementSupported
|
||||
}
|
||||
|
||||
async request<T>(type: string, payload: unknown): Promise<T> {
|
||||
this.requests.push({ type, payload })
|
||||
return (await this.handleRequest(type, payload)) as T
|
||||
}
|
||||
|
||||
onEvent(listener: (event: unknown) => void): () => void {
|
||||
this.eventListeners.add(listener)
|
||||
return () => this.eventListeners.delete(listener)
|
||||
}
|
||||
|
||||
onDisconnected(listener: () => void): () => void {
|
||||
this.disconnectedListeners.add(listener)
|
||||
return () => this.disconnectedListeners.delete(listener)
|
||||
}
|
||||
|
||||
emit(event: string, payload: unknown): void {
|
||||
for (const listener of this.eventListeners) {
|
||||
listener({ event, payload })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(client: FakeClient) {
|
||||
const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(HOST_ID)
|
||||
slot.install(
|
||||
new DaemonTerminalAuthorityAppHostTransport({
|
||||
client,
|
||||
authenticatedAuthorityHostId: HOST_ID,
|
||||
keypair: APP_KEYPAIR
|
||||
})
|
||||
)
|
||||
return await slot.connect({ onFailure: vi.fn() })
|
||||
}
|
||||
|
||||
function outcomeTransport(onFailure = vi.fn()) {
|
||||
return {
|
||||
publishBoundary: async () => {},
|
||||
publishOutcome: async () => {},
|
||||
onFailure
|
||||
}
|
||||
}
|
||||
|
||||
function admission(namespaceId = 'namespace:daemon-test') {
|
||||
const namespace: TerminalAuthorityNamespace = Object.freeze({
|
||||
authorityHostId: HOST_ID,
|
||||
namespaceId
|
||||
})
|
||||
const request = Object.freeze({
|
||||
namespace,
|
||||
candidateProcessIncarnationId: 'app-process:daemon-test',
|
||||
candidateSessionNonce: `app-session:${namespaceId}`,
|
||||
requestId: `app-request:${namespaceId}`,
|
||||
intent: 'resume' as const
|
||||
})
|
||||
const start: TerminalAuthorityNamespaceAdmissionStart = Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM,
|
||||
...request,
|
||||
appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64')
|
||||
})
|
||||
const host = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const challenge: TerminalAuthorityNamespaceAdmissionChallenge = Object.freeze({
|
||||
...start,
|
||||
currentAdmissionCas: 'admission-cas:current',
|
||||
connectionGrantId: 'daemon-grant:test',
|
||||
authenticatedTransportPrincipal: 'daemon-principal:test',
|
||||
authenticatedTransportCapability: 'daemon-capability:test',
|
||||
hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'),
|
||||
expiresAtMs: Date.now() + 30_000
|
||||
})
|
||||
const consumer = Object.freeze({
|
||||
consumerId: terminalAuthorityHostAppConsumerId(HOST_ID, APP_KEYPAIR.publicKey),
|
||||
consumerIncarnationId: start.candidateProcessIncarnationId
|
||||
})
|
||||
const grant: TerminalAuthorityNamespaceAdmissionGrant = Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
consumer,
|
||||
namespace,
|
||||
requestId: start.requestId,
|
||||
connectionGrantId: challenge.connectionGrantId,
|
||||
admissionCas: terminalAuthorityAdmissionCas(
|
||||
namespace,
|
||||
consumer.consumerId,
|
||||
consumer.consumerIncarnationId
|
||||
),
|
||||
replayed: false
|
||||
})
|
||||
return { request, start, challenge, grant }
|
||||
}
|
||||
|
||||
function retirement(namespaceId = 'namespace:daemon-test') {
|
||||
const namespace = Object.freeze({ authorityHostId: HOST_ID, namespaceId })
|
||||
const request = Object.freeze({
|
||||
namespace,
|
||||
candidateProcessIncarnationId: 'app-process:daemon-test',
|
||||
candidateSessionNonce: `app-retirement-session:${namespaceId}`,
|
||||
requestId: `app-retirement-request:${namespaceId}`
|
||||
})
|
||||
const start = Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION,
|
||||
algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM,
|
||||
...request,
|
||||
appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64')
|
||||
})
|
||||
const host = createTerminalAuthorityProofEphemeralKeypair()
|
||||
const consumerId = terminalAuthorityHostAppConsumerId(HOST_ID, APP_KEYPAIR.publicKey)
|
||||
const currentConsumerIncarnationId = request.candidateProcessIncarnationId
|
||||
const challenge = Object.freeze({
|
||||
...start,
|
||||
consumerId,
|
||||
currentConsumerIncarnationId,
|
||||
retirementCas: terminalAuthorityRetirementCas(
|
||||
namespace,
|
||||
consumerId,
|
||||
currentConsumerIncarnationId
|
||||
),
|
||||
connectionGrantId: 'daemon-grant:test',
|
||||
liveAdmission: null,
|
||||
authenticatedTransportPrincipal: 'daemon-principal:test',
|
||||
authenticatedTransportCapability: 'daemon-capability:test',
|
||||
hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'),
|
||||
expiresAtMs: Date.now() + 30_000
|
||||
})
|
||||
const result = Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION,
|
||||
namespace,
|
||||
consumerId,
|
||||
retiredConsumerIncarnationId: currentConsumerIncarnationId,
|
||||
requestId: request.requestId,
|
||||
candidateProcessIncarnationId: request.candidateProcessIncarnationId,
|
||||
candidateSessionNonce: request.candidateSessionNonce,
|
||||
connectionGrantId: challenge.connectionGrantId,
|
||||
retirementCas: challenge.retirementCas,
|
||||
retired: true as const,
|
||||
alreadyAbsent: false,
|
||||
replayed: false
|
||||
})
|
||||
return { request, start, challenge, result }
|
||||
}
|
||||
|
||||
function assertProofUsesChallenge(
|
||||
value: unknown,
|
||||
challenge: TerminalAuthorityNamespaceAdmissionChallenge
|
||||
): void {
|
||||
expect(value).toMatchObject({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
challenge,
|
||||
proofMacB64: expect.any(String)
|
||||
})
|
||||
}
|
||||
|
||||
function namespaceBoundary(grant: TerminalAuthorityNamespaceAdmissionGrant) {
|
||||
return Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION,
|
||||
consumer: grant.consumer,
|
||||
namespace: grant.namespace,
|
||||
acknowledgedSequence: 0,
|
||||
outcomeHighWatermark: 0,
|
||||
boundaryId: `boundary:${grant.namespace.namespaceId}`,
|
||||
consumerStart: 'new-at-tail' as const
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM,
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
parseTerminalAuthorityNamespaceAdmissionChallenge,
|
||||
parseTerminalAuthorityNamespaceAdmissionStart,
|
||||
type TerminalAuthorityNamespaceAdmissionChallenge,
|
||||
type TerminalAuthorityNamespaceAdmissionIntent,
|
||||
type TerminalAuthorityNamespaceAdmissionStart
|
||||
} from '../../shared/terminal-session-authority-consumer-proof'
|
||||
import {
|
||||
assertAuthorityNamespace,
|
||||
type TerminalAuthorityNamespace
|
||||
} from '../../shared/terminal-session-authority-identity'
|
||||
import {
|
||||
TerminalAuthorityAppAdmissionIntentRequiredError,
|
||||
TerminalAuthorityAppAdmissionRejectedError,
|
||||
type TerminalAuthorityAppConsumerRetirementRequest,
|
||||
type TerminalAuthorityAppNamespaceAdmissionRequest,
|
||||
type TerminalAuthorityAppOutcomeHostConnection,
|
||||
type TerminalAuthorityAppOutcomeHostTransport,
|
||||
type TerminalAuthorityAppOutcomeNamespaceConnection
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import { terminalAuthorityAppAdmissionRejection } from '../session-authority/terminal-authority-app-admission-error'
|
||||
import type { TerminalAuthorityPolicyOutcomeTransport } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
import {
|
||||
createTerminalAuthorityConsumerProof,
|
||||
type TerminalAuthorityConsumerProofKeypair
|
||||
} from '../session-authority/terminal-session-authority-consumer-proof'
|
||||
import {
|
||||
DaemonTerminalAuthorityAppNamespaceConnection,
|
||||
parseDaemonTerminalAuthorityAppNamespaceEvent
|
||||
} from './daemon-terminal-authority-app-namespace-transport'
|
||||
import {
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST
|
||||
} from './daemon-terminal-authority-consumer-requests'
|
||||
import { retireDaemonTerminalAuthorityAppConsumer } from './daemon-terminal-authority-app-retirement'
|
||||
|
||||
export type DaemonTerminalAuthorityAppClient = Readonly<{
|
||||
ensureConnected(): Promise<void>
|
||||
terminalSessionAuthorityConsumerProofHostId(): string | null
|
||||
terminalSessionAuthorityConsumerRetirementSupported?(): boolean
|
||||
request<T>(type: string, payload: unknown, timeoutMs?: number): Promise<T>
|
||||
onEvent(listener: (event: unknown) => void): () => void
|
||||
onDisconnected(listener: () => void): () => void
|
||||
}>
|
||||
|
||||
export type DaemonTerminalAuthorityAppHostTransportOptions = Readonly<{
|
||||
client: DaemonTerminalAuthorityAppClient
|
||||
authenticatedAuthorityHostId: string
|
||||
keypair: TerminalAuthorityConsumerProofKeypair
|
||||
}>
|
||||
|
||||
export class DaemonTerminalAuthorityAppHostTransport implements TerminalAuthorityAppOutcomeHostTransport {
|
||||
readonly authenticatedAuthorityHostId: string
|
||||
|
||||
constructor(private readonly options: DaemonTerminalAuthorityAppHostTransportOptions) {
|
||||
this.authenticatedAuthorityHostId = options.authenticatedAuthorityHostId
|
||||
if (options.keypair.publicKey.length !== 32 || options.keypair.secretKey.length !== 32) {
|
||||
throw new Error('daemon terminal authority proof keypair is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
async connect(
|
||||
transport: Readonly<{ onFailure(error: unknown): void }>
|
||||
): Promise<TerminalAuthorityAppOutcomeHostConnection> {
|
||||
await this.options.client.ensureConnected()
|
||||
if (
|
||||
this.options.client.terminalSessionAuthorityConsumerProofHostId() !==
|
||||
this.authenticatedAuthorityHostId
|
||||
) {
|
||||
throw new Error('daemon terminal authority host identity changed')
|
||||
}
|
||||
return new DaemonTerminalAuthorityAppHostConnection(this.options, transport.onFailure)
|
||||
}
|
||||
}
|
||||
|
||||
class DaemonTerminalAuthorityAppHostConnection implements TerminalAuthorityAppOutcomeHostConnection {
|
||||
readonly authenticatedAuthorityHostId: string
|
||||
private readonly namespaces = new Map<string, DaemonTerminalAuthorityAppNamespaceConnection>()
|
||||
private readonly removeEventListener: () => void
|
||||
private readonly removeDisconnectedListener: () => void
|
||||
private active = true
|
||||
|
||||
constructor(
|
||||
private readonly options: DaemonTerminalAuthorityAppHostTransportOptions,
|
||||
private readonly onFailure: (error: unknown) => void
|
||||
) {
|
||||
this.authenticatedAuthorityHostId = options.authenticatedAuthorityHostId
|
||||
this.removeEventListener = options.client.onEvent((event) => this.receiveEvent(event))
|
||||
this.removeDisconnectedListener = options.client.onDisconnected(() => {
|
||||
this.fail(new Error('daemon terminal authority host disconnected'))
|
||||
})
|
||||
}
|
||||
|
||||
async openNamespace(
|
||||
request: TerminalAuthorityAppNamespaceAdmissionRequest,
|
||||
transport: TerminalAuthorityPolicyOutcomeTransport,
|
||||
onOpening?: (connection: TerminalAuthorityAppOutcomeNamespaceConnection) => void
|
||||
): Promise<TerminalAuthorityAppOutcomeNamespaceConnection> {
|
||||
this.assertActive()
|
||||
const start = this.admissionStart(request)
|
||||
const challenge = await this.issueChallenge(start)
|
||||
let proof
|
||||
try {
|
||||
proof = createTerminalAuthorityConsumerProof(challenge, this.options.keypair)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`daemon terminal authority proof failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
const key = namespaceKey(start.namespace)
|
||||
if (this.namespaces.has(key)) {
|
||||
throw new Error('daemon terminal authority namespace is already open')
|
||||
}
|
||||
const connection = new DaemonTerminalAuthorityAppNamespaceConnection({
|
||||
client: this.options.client,
|
||||
proof,
|
||||
transport,
|
||||
retire: (requestId) =>
|
||||
this.retireNamespace({
|
||||
namespace: start.namespace,
|
||||
candidateProcessIncarnationId: start.candidateProcessIncarnationId,
|
||||
candidateSessionNonce: start.candidateSessionNonce,
|
||||
requestId
|
||||
}),
|
||||
isCurrent: () => this.active && this.namespaces.get(key) === connection,
|
||||
remove: () => {
|
||||
if (this.namespaces.get(key) === connection) {
|
||||
this.namespaces.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.namespaces.set(key, connection)
|
||||
try {
|
||||
onOpening?.(connection)
|
||||
await connection.open()
|
||||
this.assertActive()
|
||||
return connection
|
||||
} catch (error) {
|
||||
connection.abandon()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async resolveNamespace(worktreeId: string): Promise<TerminalAuthorityNamespace> {
|
||||
this.assertActive()
|
||||
const value = await this.options.client.request<unknown>(
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST,
|
||||
{ worktreeId }
|
||||
)
|
||||
this.assertActive()
|
||||
assertAuthorityNamespace(value)
|
||||
if (value.authorityHostId !== this.authenticatedAuthorityHostId) {
|
||||
throw new Error('daemon terminal authority namespace host changed')
|
||||
}
|
||||
return Object.freeze({ ...value })
|
||||
}
|
||||
|
||||
async retireNamespace(request: TerminalAuthorityAppConsumerRetirementRequest) {
|
||||
const result = await retireDaemonTerminalAuthorityAppConsumer(this.options, request, () =>
|
||||
this.assertActive()
|
||||
)
|
||||
const key = namespaceKey(request.namespace)
|
||||
this.namespaces.get(key)?.retired()
|
||||
this.namespaces.delete(key)
|
||||
return result
|
||||
}
|
||||
|
||||
private admissionStart(
|
||||
request: TerminalAuthorityAppNamespaceAdmissionRequest
|
||||
): TerminalAuthorityNamespaceAdmissionStart {
|
||||
const start = parseTerminalAuthorityNamespaceAdmissionStart({
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM,
|
||||
...request,
|
||||
appPublicKeyB64: Buffer.from(this.options.keypair.publicKey).toString('base64')
|
||||
})
|
||||
if (!start || start.namespace.authorityHostId !== this.authenticatedAuthorityHostId) {
|
||||
throw new Error('daemon terminal authority admission request is invalid')
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
private async issueChallenge(
|
||||
start: TerminalAuthorityNamespaceAdmissionStart
|
||||
): Promise<TerminalAuthorityNamespaceAdmissionChallenge> {
|
||||
let unsafeChallenge: unknown
|
||||
try {
|
||||
unsafeChallenge = await this.options.client.request(
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST,
|
||||
start
|
||||
)
|
||||
} catch (error) {
|
||||
const requiredIntent = admissionIntentRequired(error)
|
||||
if (requiredIntent) {
|
||||
throw new TerminalAuthorityAppAdmissionIntentRequiredError(requiredIntent)
|
||||
}
|
||||
const rejection = terminalAuthorityAppAdmissionRejection(error)
|
||||
if (rejection) {
|
||||
throw rejection
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.assertActive()
|
||||
const challenge = parseTerminalAuthorityNamespaceAdmissionChallenge(unsafeChallenge)
|
||||
if (!challenge || !sameAdmissionStart(challenge, start)) {
|
||||
throw new TerminalAuthorityAppAdmissionRejectedError(
|
||||
'daemon terminal authority challenge is invalid'
|
||||
)
|
||||
}
|
||||
return challenge
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
this.active = false
|
||||
this.removeEventListener()
|
||||
this.removeDisconnectedListener()
|
||||
for (const connection of this.namespaces.values()) {
|
||||
connection.disconnect()
|
||||
}
|
||||
this.namespaces.clear()
|
||||
}
|
||||
|
||||
private receiveEvent(value: unknown): void {
|
||||
if (!this.active || !value || typeof value !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = value as { event?: unknown; payload?: unknown }
|
||||
const parsed = parseDaemonTerminalAuthorityAppNamespaceEvent(event.event, event.payload)
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
this.namespaces.get(namespaceKey(parsed.value.namespace))?.publish(parsed)
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
this.disconnect()
|
||||
this.onFailure(error)
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (!this.active) {
|
||||
throw new Error('daemon terminal authority host connection is stale')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameAdmissionStart(
|
||||
challenge: TerminalAuthorityNamespaceAdmissionChallenge,
|
||||
start: TerminalAuthorityNamespaceAdmissionStart
|
||||
): boolean {
|
||||
return (
|
||||
challenge.version === start.version &&
|
||||
challenge.algorithm === start.algorithm &&
|
||||
sameNamespace(challenge.namespace, start.namespace) &&
|
||||
challenge.appPublicKeyB64 === start.appPublicKeyB64 &&
|
||||
challenge.candidateProcessIncarnationId === start.candidateProcessIncarnationId &&
|
||||
challenge.candidateSessionNonce === start.candidateSessionNonce &&
|
||||
challenge.requestId === start.requestId &&
|
||||
challenge.intent === start.intent
|
||||
)
|
||||
}
|
||||
|
||||
function admissionIntentRequired(error: unknown): TerminalAuthorityNamespaceAdmissionIntent | null {
|
||||
if (!(error instanceof Error)) {
|
||||
return null
|
||||
}
|
||||
const prefix = 'terminal authority namespace admission requires '
|
||||
const required = error.message.startsWith(prefix) ? error.message.slice(prefix.length) : ''
|
||||
return required === 'first' || required === 'resume' || required === 'explicit-handover'
|
||||
? required
|
||||
: null
|
||||
}
|
||||
|
||||
function sameNamespace(
|
||||
left: TerminalAuthorityNamespace,
|
||||
right: TerminalAuthorityNamespace
|
||||
): boolean {
|
||||
return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId
|
||||
}
|
||||
|
||||
function namespaceKey(namespace: TerminalAuthorityNamespace): string {
|
||||
return JSON.stringify([namespace.authorityHostId, namespace.namespaceId])
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
parseTerminalAuthorityNamespaceAdmissionGrant,
|
||||
type TerminalAuthorityNamespaceAdmissionGrant,
|
||||
type TerminalAuthorityNamespaceAdmissionProof
|
||||
} from '../../shared/terminal-session-authority-consumer-proof'
|
||||
import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement'
|
||||
import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity'
|
||||
import {
|
||||
parseTerminalAuthorityNamespaceOutcomeBoundary,
|
||||
parseTerminalAuthorityNamespaceOutcomePublication,
|
||||
parseTerminalAuthorityNamespaceBoundaryAcceptance,
|
||||
parseTerminalAuthorityNamespaceOutcomeAck,
|
||||
sameTerminalAuthorityPolicyConsumer,
|
||||
type TerminalAuthorityNamespaceOutcomeBoundary,
|
||||
type TerminalAuthorityNamespaceOutcomePublication
|
||||
} from '../../shared/terminal-session-authority-consumer-transport'
|
||||
import {
|
||||
TerminalAuthorityAppAdmissionRejectedError,
|
||||
type TerminalAuthorityAppOutcomeNamespaceConnection
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import { terminalAuthorityAppAdmissionRejection } from '../session-authority/terminal-authority-app-admission-error'
|
||||
import type { TerminalAuthorityPolicyOutcomeTransport } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
import {
|
||||
terminalAuthorityAdmissionCas,
|
||||
terminalAuthorityHostAppConsumerId
|
||||
} from '../session-authority/terminal-session-authority-consumer-proof'
|
||||
import type { DaemonTerminalAuthorityAppClient } from './daemon-terminal-authority-app-host-transport'
|
||||
import {
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST
|
||||
} from './daemon-terminal-authority-consumer-requests'
|
||||
import {
|
||||
DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST
|
||||
} from './daemon-terminal-authority-outcome-requests'
|
||||
|
||||
const MAX_PREACTIVATION_EVENTS = 256
|
||||
|
||||
export type DaemonTerminalAuthorityAppNamespaceEvent =
|
||||
| Readonly<{ kind: 'boundary'; value: TerminalAuthorityNamespaceOutcomeBoundary }>
|
||||
| Readonly<{ kind: 'outcome'; value: TerminalAuthorityNamespaceOutcomePublication }>
|
||||
|
||||
export function parseDaemonTerminalAuthorityAppNamespaceEvent(
|
||||
event: unknown,
|
||||
payload: unknown
|
||||
): DaemonTerminalAuthorityAppNamespaceEvent | null {
|
||||
if (event === 'terminalAuthorityNamespaceOutcomeBoundary') {
|
||||
const value = parseTerminalAuthorityNamespaceOutcomeBoundary(payload)
|
||||
return value ? Object.freeze({ kind: 'boundary', value }) : null
|
||||
}
|
||||
if (event === 'terminalAuthorityNamespaceOutcome') {
|
||||
const value = parseTerminalAuthorityNamespaceOutcomePublication(payload)
|
||||
return value ? Object.freeze({ kind: 'outcome', value }) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type NamespaceConnectionOptions = Readonly<{
|
||||
client: DaemonTerminalAuthorityAppClient
|
||||
proof: TerminalAuthorityNamespaceAdmissionProof
|
||||
transport: TerminalAuthorityPolicyOutcomeTransport
|
||||
retire: (requestId: string) => Promise<TerminalAuthorityConsumerRetirementResult>
|
||||
isCurrent: () => boolean
|
||||
remove: () => void
|
||||
}>
|
||||
|
||||
export class DaemonTerminalAuthorityAppNamespaceConnection implements TerminalAuthorityAppOutcomeNamespaceConnection {
|
||||
readonly expectedConsumer
|
||||
private readonly buffered: DaemonTerminalAuthorityAppNamespaceEvent[] = []
|
||||
private installedGrant: TerminalAuthorityNamespaceAdmissionGrant | null = null
|
||||
private deliveryTail: Promise<void> = Promise.resolve()
|
||||
private active = true
|
||||
private activated = false
|
||||
|
||||
constructor(private readonly options: NamespaceConnectionOptions) {
|
||||
this.expectedConsumer = Object.freeze({
|
||||
consumerId: terminalAuthorityHostAppConsumerId(
|
||||
options.proof.challenge.namespace.authorityHostId,
|
||||
Uint8Array.from(Buffer.from(options.proof.challenge.appPublicKeyB64, 'base64'))
|
||||
),
|
||||
consumerIncarnationId: options.proof.challenge.candidateProcessIncarnationId
|
||||
})
|
||||
}
|
||||
|
||||
get grant(): TerminalAuthorityNamespaceAdmissionGrant {
|
||||
if (!this.installedGrant) {
|
||||
throw new Error('daemon terminal authority namespace grant is unavailable')
|
||||
}
|
||||
return this.installedGrant
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
this.assertActive()
|
||||
let unsafeGrant: unknown
|
||||
try {
|
||||
unsafeGrant = await this.options.client.request<unknown>(
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST,
|
||||
this.options.proof
|
||||
)
|
||||
} catch (error) {
|
||||
throw terminalAuthorityAppAdmissionRejection(error) ?? error
|
||||
}
|
||||
this.assertActive()
|
||||
const grant = parseTerminalAuthorityNamespaceAdmissionGrant(unsafeGrant)
|
||||
this.assertGrant(grant)
|
||||
this.installedGrant = grant
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
this.assertActive()
|
||||
void this.grant
|
||||
if (this.activated) {
|
||||
return await this.deliveryTail
|
||||
}
|
||||
this.activated = true
|
||||
const buffered = this.buffered.splice(0)
|
||||
let settled = Promise.resolve()
|
||||
for (const event of buffered) {
|
||||
settled = this.enqueue(event)
|
||||
}
|
||||
await settled
|
||||
}
|
||||
|
||||
async acceptBoundary(unsafeAcceptance: unknown): Promise<void> {
|
||||
this.assertActive()
|
||||
const acceptance = parseTerminalAuthorityNamespaceBoundaryAcceptance(unsafeAcceptance)
|
||||
if (!acceptance || !this.matchesExpected(acceptance.consumer, acceptance.namespace)) {
|
||||
throw new Error('daemon terminal authority boundary acceptance is invalid')
|
||||
}
|
||||
await this.options.client.request(DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST, acceptance)
|
||||
}
|
||||
|
||||
async acknowledge(unsafeAck: unknown): Promise<number> {
|
||||
this.assertActive()
|
||||
const ack = parseTerminalAuthorityNamespaceOutcomeAck(unsafeAck)
|
||||
if (!ack || !this.matchesGrant(ack.consumer, ack.namespace)) {
|
||||
throw new Error('daemon terminal authority outcome ACK is invalid')
|
||||
}
|
||||
const result = await this.options.client.request<{ acknowledgedSequence?: unknown }>(
|
||||
DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST,
|
||||
ack
|
||||
)
|
||||
if (!Number.isSafeInteger(result.acknowledgedSequence)) {
|
||||
throw new Error('daemon terminal authority outcome ACK is invalid')
|
||||
}
|
||||
return Number(result.acknowledgedSequence)
|
||||
}
|
||||
|
||||
async retire(requestId: string): Promise<TerminalAuthorityConsumerRetirementResult> {
|
||||
this.assertActive()
|
||||
const result = await this.options.retire(requestId)
|
||||
this.close(false)
|
||||
return result
|
||||
}
|
||||
|
||||
retired(): void {
|
||||
this.close(false)
|
||||
}
|
||||
|
||||
publish(event: DaemonTerminalAuthorityAppNamespaceEvent): void {
|
||||
if (!this.active || !this.matchesExpected(event.value.consumer, event.value.namespace)) {
|
||||
this.fail(new Error('daemon terminal authority namespace outcome is invalid'))
|
||||
return
|
||||
}
|
||||
if (!this.activated && event.kind === 'outcome') {
|
||||
if (this.buffered.length >= MAX_PREACTIVATION_EVENTS) {
|
||||
this.fail(new Error('daemon terminal authority preactivation events exceeded capacity'))
|
||||
return
|
||||
}
|
||||
this.buffered.push(event)
|
||||
return
|
||||
}
|
||||
void this.enqueue(event).catch(() => undefined)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.close(true)
|
||||
}
|
||||
|
||||
abandon(): void {
|
||||
this.close(false)
|
||||
}
|
||||
|
||||
private close(cancel: boolean): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
this.active = false
|
||||
this.buffered.length = 0
|
||||
this.options.remove()
|
||||
if (!cancel) {
|
||||
return
|
||||
}
|
||||
const challenge = this.options.proof.challenge
|
||||
const grant = this.installedGrant
|
||||
void this.options.client
|
||||
.request(
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST,
|
||||
{
|
||||
version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION,
|
||||
consumer: grant?.consumer ?? this.expectedConsumer,
|
||||
namespace: grant?.namespace ?? challenge.namespace,
|
||||
requestId: grant?.requestId ?? challenge.requestId,
|
||||
connectionGrantId: grant?.connectionGrantId ?? challenge.connectionGrantId
|
||||
},
|
||||
5_000
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
private enqueue(event: DaemonTerminalAuthorityAppNamespaceEvent): Promise<void> {
|
||||
const delivery = this.deliveryTail.then(async () => {
|
||||
this.assertActive()
|
||||
await (event.kind === 'boundary'
|
||||
? this.options.transport.publishBoundary(event.value)
|
||||
: this.options.transport.publishOutcome(event.value))
|
||||
})
|
||||
this.deliveryTail = delivery.catch(() => undefined)
|
||||
void delivery.catch((error) => this.fail(error))
|
||||
return delivery
|
||||
}
|
||||
|
||||
private assertGrant(
|
||||
grant: TerminalAuthorityNamespaceAdmissionGrant | null
|
||||
): asserts grant is TerminalAuthorityNamespaceAdmissionGrant {
|
||||
const challenge = this.options.proof.challenge
|
||||
if (
|
||||
!grant ||
|
||||
!sameTerminalAuthorityPolicyConsumer(grant.consumer, this.expectedConsumer) ||
|
||||
!sameNamespace(grant.namespace, challenge.namespace) ||
|
||||
grant.requestId !== challenge.requestId ||
|
||||
grant.connectionGrantId !== challenge.connectionGrantId ||
|
||||
grant.admissionCas !==
|
||||
terminalAuthorityAdmissionCas(
|
||||
challenge.namespace,
|
||||
this.expectedConsumer.consumerId,
|
||||
this.expectedConsumer.consumerIncarnationId
|
||||
)
|
||||
) {
|
||||
throw new TerminalAuthorityAppAdmissionRejectedError(
|
||||
'daemon terminal authority grant is invalid'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private matchesGrant(
|
||||
consumer: TerminalAuthorityNamespaceOutcomeBoundary['consumer'],
|
||||
namespace: TerminalAuthorityNamespace
|
||||
): boolean {
|
||||
return (
|
||||
this.installedGrant !== null &&
|
||||
sameTerminalAuthorityPolicyConsumer(this.installedGrant.consumer, consumer) &&
|
||||
sameNamespace(this.installedGrant.namespace, namespace)
|
||||
)
|
||||
}
|
||||
|
||||
private matchesExpected(
|
||||
consumer: TerminalAuthorityNamespaceOutcomeBoundary['consumer'],
|
||||
namespace: TerminalAuthorityNamespace
|
||||
): boolean {
|
||||
return (
|
||||
sameTerminalAuthorityPolicyConsumer(this.expectedConsumer, consumer) &&
|
||||
sameNamespace(this.options.proof.challenge.namespace, namespace)
|
||||
)
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
this.disconnect()
|
||||
try {
|
||||
this.options.transport.onFailure?.(error instanceof Error ? error : new Error(String(error)))
|
||||
} catch {
|
||||
// The namespace is already fenced.
|
||||
}
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (!this.active || !this.options.isCurrent()) {
|
||||
throw new Error('daemon terminal authority namespace connection is stale')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameNamespace(
|
||||
left: TerminalAuthorityNamespace,
|
||||
right: TerminalAuthorityNamespace
|
||||
): boolean {
|
||||
return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TerminalAuthorityAppConsumerRetirementRequest } from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import { retireTerminalAuthorityAppConsumer } from '../session-authority/terminal-authority-app-consumer-retirement'
|
||||
import type { DaemonTerminalAuthorityAppHostTransportOptions } from './daemon-terminal-authority-app-host-transport'
|
||||
import {
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST,
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST
|
||||
} from './daemon-terminal-authority-consumer-requests'
|
||||
|
||||
export async function retireDaemonTerminalAuthorityAppConsumer(
|
||||
options: DaemonTerminalAuthorityAppHostTransportOptions,
|
||||
request: TerminalAuthorityAppConsumerRetirementRequest,
|
||||
assertCurrent: () => void
|
||||
) {
|
||||
assertCurrent()
|
||||
if (!options.client.terminalSessionAuthorityConsumerRetirementSupported?.()) {
|
||||
throw new Error('daemon terminal authority consumer retirement is unsupported')
|
||||
}
|
||||
const result = await retireTerminalAuthorityAppConsumer({
|
||||
authenticatedAuthorityHostId: options.authenticatedAuthorityHostId,
|
||||
keypair: options.keypair,
|
||||
request,
|
||||
issueChallenge: (start) =>
|
||||
options.client.request(
|
||||
DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST,
|
||||
start
|
||||
),
|
||||
complete: (proof) =>
|
||||
options.client.request(DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST, proof)
|
||||
})
|
||||
assertCurrent()
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
TerminalAuthorityNamespaceAdmissionCancellation,
|
||||
TerminalAuthorityNamespaceAdmissionProof,
|
||||
TerminalAuthorityNamespaceAdmissionStart
|
||||
} from '../../shared/terminal-session-authority-consumer-proof'
|
||||
import type {
|
||||
TerminalAuthorityConsumerRetirementProof,
|
||||
TerminalAuthorityConsumerRetirementStart
|
||||
} from '../../shared/terminal-session-authority-consumer-retirement'
|
||||
|
||||
export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST =
|
||||
'beginTerminalAuthorityConsumerAdmission' as const
|
||||
export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST =
|
||||
'completeTerminalAuthorityConsumerAdmission' as const
|
||||
export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST =
|
||||
'cancelTerminalAuthorityConsumerAdmission' as const
|
||||
export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST =
|
||||
'resolveTerminalAuthorityConsumerNamespace' as const
|
||||
export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST =
|
||||
'beginTerminalAuthorityConsumerRetirement' as const
|
||||
export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST =
|
||||
'completeTerminalAuthorityConsumerRetirement' as const
|
||||
|
||||
export type DaemonTerminalAuthorityConsumerRequest =
|
||||
| Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST
|
||||
payload: Readonly<{ worktreeId: string }>
|
||||
}>
|
||||
| Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST
|
||||
payload: TerminalAuthorityNamespaceAdmissionStart
|
||||
}>
|
||||
| Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST
|
||||
payload: TerminalAuthorityNamespaceAdmissionProof
|
||||
}>
|
||||
| Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST
|
||||
payload: TerminalAuthorityNamespaceAdmissionCancellation
|
||||
}>
|
||||
| Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST
|
||||
payload: TerminalAuthorityConsumerRetirementStart
|
||||
}>
|
||||
| Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST
|
||||
payload: TerminalAuthorityConsumerRetirementProof
|
||||
}>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
TerminalAuthorityNamespaceBoundaryAcceptance,
|
||||
TerminalAuthorityNamespaceOutcomeAck,
|
||||
TerminalAuthorityPolicyConsumerRetirement
|
||||
} from '../../shared/terminal-session-authority-consumer-transport'
|
||||
|
||||
export const DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST =
|
||||
'ackTerminalAuthorityNamespaceOutcome' as const
|
||||
export const DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST =
|
||||
'acceptTerminalAuthorityNamespaceBoundary' as const
|
||||
|
||||
export type AcknowledgeTerminalAuthorityNamespaceOutcomeRequest = Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST
|
||||
payload: TerminalAuthorityNamespaceOutcomeAck
|
||||
}>
|
||||
|
||||
export type AcceptTerminalAuthorityNamespaceBoundaryRequest = Readonly<{
|
||||
id: string
|
||||
type: typeof DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST
|
||||
payload: TerminalAuthorityNamespaceBoundaryAcceptance
|
||||
}>
|
||||
|
||||
export type RetireTerminalAuthorityPolicyConsumerRequest = Readonly<{
|
||||
id: string
|
||||
type: 'retireTerminalAuthorityPolicyConsumer'
|
||||
payload: TerminalAuthorityPolicyConsumerRetirement
|
||||
}>
|
||||
|
||||
export type DaemonTerminalAuthorityOutcomeRequest =
|
||||
| AcceptTerminalAuthorityNamespaceBoundaryRequest
|
||||
| AcknowledgeTerminalAuthorityNamespaceOutcomeRequest
|
||||
| RetireTerminalAuthorityPolicyConsumerRequest
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonServer, type DaemonServerOptions } from './daemon-server'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { DaemonHelloCapabilities, HelloResponse } from './daemon-hello-protocol'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
import type { TerminalAuthorityPolicyConsumerClaim } from '../../shared/terminal-session-authority-consumer-transport'
|
||||
|
||||
type Readiness = { host: boolean }
|
||||
|
||||
const servers: DaemonServer[] = []
|
||||
const clients: DaemonClient[] = []
|
||||
const directories: string[] = []
|
||||
const POLICY_CLAIM: TerminalAuthorityPolicyConsumerClaim = Object.freeze({
|
||||
version: 1,
|
||||
consumer: Object.freeze({
|
||||
consumerId: 'app-profile:daemon-capability-test',
|
||||
consumerIncarnationId: 'app-process:daemon-capability-test'
|
||||
}),
|
||||
expectedConsumerIncarnationId: null
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const client of clients.splice(0)) {
|
||||
client.disconnect()
|
||||
}
|
||||
for (const server of servers.splice(0)) {
|
||||
await server.shutdown()
|
||||
}
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('daemon terminal session authority capability', () => {
|
||||
it.each([
|
||||
{ host: true, authority: true, expected: true },
|
||||
{ host: false, authority: true, expected: false },
|
||||
{ host: true, authority: false, expected: false }
|
||||
])('echoes only with host=$host authority=$authority', async ({ host, authority, expected }) => {
|
||||
const readiness = { host }
|
||||
const harness = await startServer(readiness, { authority })
|
||||
const client = new DaemonClient({
|
||||
...harness,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([34, 35])('does not echo to a version-only v%s client', async (protocolVersion) => {
|
||||
const harness = await startServer({ host: true }, { authority: true, protocolVersion })
|
||||
const response = await exchangeVersionOnlyHello(harness, protocolVersion)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.capabilities).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not downgrade an unnegotiated proof offer to a client-supplied consumer', async () => {
|
||||
const harness = await startServer({ host: true }, { authority: true })
|
||||
const response = await exchangeHelloCapabilities(harness, {
|
||||
terminalSessionAuthority: 1,
|
||||
terminalAuthorityConsumerProof: { versions: [2] },
|
||||
terminalAuthorityNamespaceOutcomes: POLICY_CLAIM
|
||||
} as DaemonHelloCapabilities)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.capabilities).toBeUndefined()
|
||||
})
|
||||
|
||||
it('negotiates no authority for a proofless peer that names its own consumer', async () => {
|
||||
const harness = await startServer({ host: true }, { authority: true })
|
||||
const response = await exchangeHelloCapabilities(harness, {
|
||||
terminalSessionAuthority: 1,
|
||||
terminalAuthorityNamespaceOutcomes: POLICY_CLAIM
|
||||
} as DaemonHelloCapabilities)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.capabilities).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses authority create metadata from a proofless peer before any spawn preparation', async () => {
|
||||
const preparePtySpawn = vi.fn(async () => undefined)
|
||||
const spawnSubprocess = vi.fn(() => {
|
||||
throw new Error('spawn must not run')
|
||||
})
|
||||
const harness = await startServer(
|
||||
{ host: true },
|
||||
{ authority: true, preparePtySpawn, spawnSubprocess }
|
||||
)
|
||||
const client = new DaemonClient({ ...harness })
|
||||
clients.push(client)
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(false)
|
||||
await expect(client.request('createOrAttach', authorityCreatePayload())).rejects.toThrow(
|
||||
'terminal_session_authority_unavailable'
|
||||
)
|
||||
expect(preparePtySpawn).not.toHaveBeenCalled()
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects authority metadata before spawn preparation when capability was not negotiated', async () => {
|
||||
const preparePtySpawn = vi.fn(async () => undefined)
|
||||
const spawnSubprocess = vi.fn(() => {
|
||||
throw new Error('spawn must not run')
|
||||
})
|
||||
const harness = await startServer(
|
||||
{ host: false },
|
||||
{ authority: true, preparePtySpawn, spawnSubprocess }
|
||||
)
|
||||
const client = new DaemonClient({
|
||||
...harness,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
clients.push(client)
|
||||
await client.ensureConnected()
|
||||
|
||||
await expect(client.request('createOrAttach', authorityCreatePayload())).rejects.toThrow(
|
||||
'terminal_session_authority_unavailable'
|
||||
)
|
||||
expect(preparePtySpawn).not.toHaveBeenCalled()
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails before spawn preparation when host effects become unavailable', async () => {
|
||||
const readiness = { host: true }
|
||||
const preparePtySpawn = vi.fn(async () => undefined)
|
||||
const spawnSubprocess = vi.fn(() => {
|
||||
throw new Error('spawn must not run')
|
||||
})
|
||||
const harness = await startServer(readiness, {
|
||||
authority: true,
|
||||
preparePtySpawn,
|
||||
spawnSubprocess
|
||||
})
|
||||
const client = new DaemonClient({
|
||||
...harness,
|
||||
terminalSessionAuthorityConsumerProofReady: () => true
|
||||
})
|
||||
clients.push(client)
|
||||
await client.ensureConnected()
|
||||
expect(client.supportsTerminalSessionAuthority()).toBe(true)
|
||||
readiness.host = false
|
||||
|
||||
await expect(client.request('createOrAttach', authorityCreatePayload())).rejects.toThrow(
|
||||
'terminal_session_authority_unavailable'
|
||||
)
|
||||
expect(preparePtySpawn).not.toHaveBeenCalled()
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
async function startServer(
|
||||
readiness: Readiness,
|
||||
options: {
|
||||
authority: boolean
|
||||
protocolVersion?: number
|
||||
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
|
||||
spawnSubprocess?: DaemonServerOptions['spawnSubprocess']
|
||||
}
|
||||
): Promise<{ socketPath: string; tokenPath: string; protocolVersion?: number }> {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'daemon-authority-capability-'))
|
||||
directories.push(directory)
|
||||
const socketPath = getDaemonSocketPath(directory)
|
||||
const tokenPath = join(directory, 'daemon.token')
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(options.protocolVersion !== undefined ? { protocolVersion: options.protocolVersion } : {}),
|
||||
spawnSubprocess:
|
||||
options.spawnSubprocess ??
|
||||
(() => {
|
||||
throw new Error('unexpected spawn')
|
||||
}),
|
||||
...(options.preparePtySpawn ? { preparePtySpawn: options.preparePtySpawn } : {}),
|
||||
...(options.authority ? { terminalSessionAuthority: authorityComponents() } : {}),
|
||||
terminalSessionAuthorityCapabilityReadiness: {
|
||||
hostEffectConsumerInstalled: () => readiness.host
|
||||
}
|
||||
})
|
||||
servers.push(server)
|
||||
await server.start()
|
||||
return {
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(options.protocolVersion !== undefined ? { protocolVersion: options.protocolVersion } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function authorityComponents(): NonNullable<DaemonServerOptions['terminalSessionAuthority']> {
|
||||
return {
|
||||
authorityHostId: 'authority-host:daemon-capability-test',
|
||||
ptyOwner: {
|
||||
releaseAuthenticatedPolicyConsumerTransport: () => {}
|
||||
} as unknown as TerminalSessionAuthorityPtyOwner
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeHelloCapabilities(
|
||||
harness: { socketPath: string; tokenPath: string; protocolVersion?: number },
|
||||
capabilities: DaemonHelloCapabilities
|
||||
): Promise<HelloResponse> {
|
||||
const socket = await connectSocket(harness.socketPath)
|
||||
try {
|
||||
const response = readHelloResponse(socket)
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
version: harness.protocolVersion ?? PROTOCOL_VERSION,
|
||||
token: readFileSync(harness.tokenPath, 'utf8').trim(),
|
||||
clientId: 'capability-client',
|
||||
role: 'control',
|
||||
capabilities
|
||||
})
|
||||
)
|
||||
return await response
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function authorityCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: 'authority-session',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
terminalSessionAuthorityVersion: 1,
|
||||
terminalSessionAuthorityOperationId: 'operation-a',
|
||||
worktreeId: 'repo::/srv/repo',
|
||||
paneKey: 'pane-a',
|
||||
paneGeneration: 1
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeVersionOnlyHello(
|
||||
harness: { socketPath: string; tokenPath: string },
|
||||
protocolVersion: number
|
||||
): Promise<HelloResponse> {
|
||||
const socket = await connectSocket(harness.socketPath)
|
||||
try {
|
||||
const response = readHelloResponse(socket)
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token: readFileSync(harness.tokenPath, 'utf8').trim(),
|
||||
clientId: 'version-only-client',
|
||||
role: 'control'
|
||||
})
|
||||
)
|
||||
return await response
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function connectSocket(socketPath: string): Promise<Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(socketPath)
|
||||
socket.once('connect', () => resolve(socket))
|
||||
socket.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function readHelloResponse(socket: Socket): Promise<HelloResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = ''
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline !== -1) {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)) as HelloResponse)
|
||||
}
|
||||
})
|
||||
socket.once('error', reject)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions } from '../providers/types'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
type ExactDataEvent = { id: string; data: string; incarnationId?: string }
|
||||
|
||||
type ProviderMock = IPtyProvider & {
|
||||
rows: PtyProcessInfo[]
|
||||
emitData: (payload: ExactDataEvent) => void
|
||||
emitIdentityChange: () => void
|
||||
}
|
||||
|
||||
function authorityAccess(
|
||||
physicalPtyId: string,
|
||||
ptyIncarnationId: string
|
||||
): TerminalSessionAuthorityPtyAccess {
|
||||
return {
|
||||
namespace: { authorityHostId: 'authority-host', namespaceId: 'namespace' },
|
||||
pane: { paneKey: physicalPtyId, paneGenerationId: 'renderer:1' },
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-incarnation',
|
||||
physicalPtyId,
|
||||
ptyIncarnationId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listed(id: string, incarnationId: string, token: object): PtyProcessInfo {
|
||||
return {
|
||||
id,
|
||||
incarnationId,
|
||||
mutationRouteToken: token,
|
||||
terminalSessionAuthorityAccess: authorityAccess(id, incarnationId),
|
||||
cwd: '',
|
||||
title: 'shell'
|
||||
}
|
||||
}
|
||||
|
||||
function createProvider(rows: PtyProcessInfo[]): ProviderMock {
|
||||
const dataListeners: ((payload: ExactDataEvent) => void)[] = []
|
||||
const identityListeners: (() => void)[] = []
|
||||
const provider = {
|
||||
rows,
|
||||
spawn: vi.fn(async (opts: PtySpawnOptions) => ({ id: opts.sessionId ?? 'spawned' })),
|
||||
attach: vi.fn(async () => {}),
|
||||
hasPty: vi.fn((id: string) => rows.some((row) => row.id === id)),
|
||||
probePtyLiveness: vi.fn(async (id: string) => rows.some((row) => row.id === id)),
|
||||
providesAgentSessionOwnerListings: vi.fn(() => true),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
supportsExactPtyOperations: vi.fn(() => true),
|
||||
getPtyMutationMode: vi.fn(() => 'exact'),
|
||||
writeExact: vi.fn(() => true),
|
||||
resizeExact: vi.fn(() => true),
|
||||
killExact: vi.fn(async () => true),
|
||||
sendSignalExact: vi.fn(async () => true),
|
||||
clearBufferExact: vi.fn(async () => true),
|
||||
writeAuthorityExact: vi.fn(() => true),
|
||||
resizeAuthorityExact: vi.fn(() => true),
|
||||
killAuthorityExact: vi.fn(async () => true),
|
||||
sendSignalAuthorityExact: vi.fn(async () => true),
|
||||
clearBufferAuthorityExact: vi.fn(async () => true),
|
||||
bindTerminalSessionAuthorityAccess: vi.fn(() => true),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
sendSignal: vi.fn(async () => {}),
|
||||
getCwd: vi.fn(async () => ''),
|
||||
getInitialCwd: vi.fn(async () => ''),
|
||||
clearBuffer: vi.fn(async () => {}),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(async () => false),
|
||||
getForegroundProcess: vi.fn(async () => null),
|
||||
serialize: vi.fn(async () => '{}'),
|
||||
revive: vi.fn(async () => {}),
|
||||
listProcesses: vi.fn(async () => rows),
|
||||
getPtyMutationRouteToken: vi.fn(
|
||||
(id: string) => rows.find((row) => row.id === id)?.mutationRouteToken ?? null
|
||||
),
|
||||
getDefaultShell: vi.fn(async () => '/bin/sh'),
|
||||
getProfiles: vi.fn(async () => []),
|
||||
onData: vi.fn((callback: (payload: ExactDataEvent) => void) => {
|
||||
dataListeners.push(callback)
|
||||
return () => {}
|
||||
}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
onWriteUnavailable: vi.fn(() => () => {}),
|
||||
onDaemonIdentityChanged: vi.fn((callback: () => void) => {
|
||||
identityListeners.push(callback)
|
||||
return () => {}
|
||||
}),
|
||||
reconcileOnStartup: vi.fn(async () => ({ alive: [], killed: [] })),
|
||||
ackColdRestore: vi.fn(),
|
||||
clearTombstone: vi.fn(),
|
||||
disconnectOnly: vi.fn(async () => {}),
|
||||
dispose: vi.fn(),
|
||||
emitData: (payload: ExactDataEvent) => {
|
||||
for (const listener of dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
},
|
||||
emitIdentityChange: () => {
|
||||
for (const listener of identityListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider as unknown as ProviderMock
|
||||
}
|
||||
|
||||
function asDaemon(provider: ProviderMock): DaemonPtyAdapter {
|
||||
return provider as unknown as DaemonPtyAdapter
|
||||
}
|
||||
|
||||
describe('DegradedDaemonPtyProvider authority routing', () => {
|
||||
it('forwards authority operations to current and legacy daemon owners', async () => {
|
||||
const current = createProvider([listed('current-session', 'current-incarnation', {})])
|
||||
const legacy = createProvider([listed('legacy-session', 'legacy-incarnation', {})])
|
||||
const fallback = createProvider([])
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: asDaemon(current),
|
||||
legacy: [asDaemon(legacy)],
|
||||
fallback
|
||||
})
|
||||
await provider.listProcesses()
|
||||
|
||||
for (const [owner, id, incarnationId] of [
|
||||
[current, 'current-session', 'current-incarnation'],
|
||||
[legacy, 'legacy-session', 'legacy-incarnation']
|
||||
] as const) {
|
||||
const access = authorityAccess(id, incarnationId)
|
||||
expect(provider.bindTerminalSessionAuthorityAccess(id, access)).toBe(true)
|
||||
expect(provider.writeAuthorityExact(id, access, 'input')).toBe(true)
|
||||
expect(provider.resizeAuthorityExact(id, access, 120, 40)).toBe(true)
|
||||
await expect(provider.sendSignalAuthorityExact(id, access, 'SIGTERM')).resolves.toBe(true)
|
||||
await expect(provider.clearBufferAuthorityExact(id, access)).resolves.toBe(true)
|
||||
await expect(provider.killAuthorityExact(id, access, { immediate: true })).resolves.toBe(true)
|
||||
expect(owner.writeAuthorityExact).toHaveBeenCalledWith(id, access, 'input')
|
||||
expect(owner.killAuthorityExact).toHaveBeenCalledWith(id, access, { immediate: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed for a fallback-versus-daemon collision', async () => {
|
||||
const access = authorityAccess('collision', 'incarnation')
|
||||
const current = createProvider([listed('collision', 'incarnation', {})])
|
||||
const fallback = createProvider([listed('collision', 'incarnation', {})])
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: asDaemon(current),
|
||||
legacy: [],
|
||||
fallback
|
||||
})
|
||||
|
||||
const inventory = await provider.listProcesses()
|
||||
|
||||
expect(inventory.every((row) => row.mutationRouteToken === undefined)).toBe(true)
|
||||
expect(provider.getPtyMutationRouteToken('collision')).toBeNull()
|
||||
expect(provider.writeExact('collision', 'incarnation', 'blocked')).toBe(false)
|
||||
expect(provider.writeAuthorityExact('collision', access, 'blocked')).toBe(false)
|
||||
expect(provider.bindTerminalSessionAuthorityAccess('collision', access)).toBe(false)
|
||||
expect(current.writeAuthorityExact).not.toHaveBeenCalled()
|
||||
expect(fallback.writeAuthorityExact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves route identity with inventory and invalidates it on daemon replacement', async () => {
|
||||
const oldToken = {}
|
||||
const newToken = {}
|
||||
const current = createProvider([])
|
||||
const legacy = createProvider([listed('moving', 'incarnation', oldToken)])
|
||||
const fallback = createProvider([])
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: asDaemon(current),
|
||||
legacy: [asDaemon(legacy)],
|
||||
fallback
|
||||
})
|
||||
const [oldRow] = await provider.listProcesses()
|
||||
legacy.rows.splice(0)
|
||||
fallback.rows.push(listed('moving', 'incarnation', newToken))
|
||||
const [newRow] = await provider.listProcesses()
|
||||
|
||||
expect(oldRow?.mutationRouteToken).toBe(oldToken)
|
||||
expect(newRow?.mutationRouteToken).toBe(newToken)
|
||||
expect(provider.getPtyMutationRouteToken('moving')).toBe(newToken)
|
||||
const access = authorityAccess('moving', 'incarnation')
|
||||
expect(provider.writeAuthorityExact('moving', access, 'fallback')).toBe(true)
|
||||
expect(fallback.writeAuthorityExact).toHaveBeenCalledWith('moving', access, 'fallback')
|
||||
|
||||
fallback.rows.splice(0)
|
||||
current.rows.push(listed('moving', 'incarnation', {}))
|
||||
await provider.listProcesses()
|
||||
current.emitIdentityChange()
|
||||
expect(provider.getPtyMutationRouteToken('moving')).toBeNull()
|
||||
expect(provider.writeAuthorityExact('moving', access, 'blocked')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects aggregate inventory when any provider list fails', async () => {
|
||||
const current = createProvider([])
|
||||
const fallback = createProvider([])
|
||||
vi.mocked(current.listProcesses).mockRejectedValue(new Error('daemon unavailable'))
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: asDaemon(current),
|
||||
legacy: [],
|
||||
fallback
|
||||
})
|
||||
|
||||
await expect(provider.listProcesses()).rejects.toThrow('daemon unavailable')
|
||||
})
|
||||
|
||||
it('preserves the admitted incarnation through degraded data fanout', () => {
|
||||
const current = createProvider([])
|
||||
const fallback = createProvider([])
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: asDaemon(current),
|
||||
legacy: [],
|
||||
fallback
|
||||
})
|
||||
const received: ExactDataEvent[] = []
|
||||
provider.onData((payload) => received.push(payload))
|
||||
|
||||
current.emitData({ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' })
|
||||
|
||||
expect(received).toEqual([{ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' }])
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ function createProvider(
|
||||
sessions: string[] = [],
|
||||
authoritativeOwnerListings = false
|
||||
): ProviderMock {
|
||||
const mutationRouteToken = Object.freeze({ label })
|
||||
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
|
||||
[]
|
||||
const replayListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
@@ -34,6 +35,15 @@ function createProvider(
|
||||
}),
|
||||
attach: vi.fn(async () => {}),
|
||||
hasPty: vi.fn((id: string) => sessions.includes(id)),
|
||||
supportsExactPtyOperations: vi.fn((id: string) => sessions.includes(id)),
|
||||
supportsExactHeldProducerPause: vi.fn((id: string) => sessions.includes(id)),
|
||||
acquireExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
releaseExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
writeExact: vi.fn((id: string) => sessions.includes(id)),
|
||||
resizeExact: vi.fn((id: string) => sessions.includes(id)),
|
||||
killExact: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
sendSignalExact: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
clearBufferExact: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings),
|
||||
write: vi.fn(),
|
||||
@@ -55,7 +65,12 @@ function createProvider(
|
||||
confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`),
|
||||
serialize: vi.fn(async () => '{}'),
|
||||
revive: vi.fn(async () => {}),
|
||||
listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))),
|
||||
listProcesses: vi.fn(async () =>
|
||||
sessions.map((id) => ({ id, mutationRouteToken, cwd: '', title: label }))
|
||||
),
|
||||
getPtyMutationRouteToken: vi.fn((id: string) =>
|
||||
sessions.includes(id) ? mutationRouteToken : null
|
||||
),
|
||||
getDefaultShell: vi.fn(async () => '/bin/zsh'),
|
||||
getProfiles: vi.fn(async () => []),
|
||||
onData: vi.fn(
|
||||
@@ -298,6 +313,68 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
expect(fallback.write).toHaveBeenCalledWith(fresh.id, 'new\n')
|
||||
})
|
||||
|
||||
it('routes every exact mutation to the provider that owns the incarnation', async () => {
|
||||
const current = createDaemonAdapter('daemon', ['daemon-session'])
|
||||
const fallback = createProvider('fallback')
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
|
||||
await provider.discoverDaemonSessions()
|
||||
const fresh = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
expect(provider.writeExact('daemon-session', 'daemon-incarnation', 'daemon input')).toBe(true)
|
||||
expect(provider.resizeExact(fresh.id, 'fallback-incarnation', 100, 30)).toBe(true)
|
||||
await expect(
|
||||
provider.sendSignalExact('daemon-session', 'daemon-incarnation', 'SIGTERM')
|
||||
).resolves.toBe(true)
|
||||
await expect(provider.clearBufferExact(fresh.id, 'fallback-incarnation')).resolves.toBe(true)
|
||||
await expect(
|
||||
provider.killExact('daemon-session', 'daemon-incarnation', { immediate: true })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(current.writeExact).toHaveBeenCalledWith(
|
||||
'daemon-session',
|
||||
'daemon-incarnation',
|
||||
'daemon input'
|
||||
)
|
||||
expect(fallback.resizeExact).toHaveBeenCalledWith(fresh.id, 'fallback-incarnation', 100, 30)
|
||||
expect(current.sendSignalExact).toHaveBeenCalledWith(
|
||||
'daemon-session',
|
||||
'daemon-incarnation',
|
||||
'SIGTERM'
|
||||
)
|
||||
expect(fallback.clearBufferExact).toHaveBeenCalledWith(fresh.id, 'fallback-incarnation')
|
||||
expect(current.killExact).toHaveBeenCalledWith('daemon-session', 'daemon-incarnation', {
|
||||
immediate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('routes exact held-pause leases across daemon and local fallback owners', async () => {
|
||||
const current = createDaemonAdapter('daemon', ['daemon-session'])
|
||||
const fallback = createProvider('fallback')
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
|
||||
await provider.discoverDaemonSessions()
|
||||
const fresh = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(
|
||||
provider.acquireExactHeldProducerPause('daemon-session', 'daemon-incarnation', 'daemon-lease')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
provider.acquireExactHeldProducerPause(fresh.id, 'local-incarnation', 'local-lease')
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
provider.releaseExactHeldProducerPause(fresh.id, 'local-incarnation', 'local-lease')
|
||||
).resolves.toBe(true)
|
||||
expect(current.acquireExactHeldProducerPause).toHaveBeenCalledWith(
|
||||
'daemon-session',
|
||||
'daemon-incarnation',
|
||||
'daemon-lease'
|
||||
)
|
||||
expect(fallback.acquireExactHeldProducerPause).toHaveBeenCalledWith(
|
||||
fresh.id,
|
||||
'local-incarnation',
|
||||
'local-lease'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes later fresh PTYs to the daemon after spawn health recovers', async () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const fallback = createProvider('fallback')
|
||||
|
||||
@@ -19,8 +19,13 @@ import {
|
||||
} from './degraded-daemon-session-routing'
|
||||
import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing'
|
||||
import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery'
|
||||
import { reconcileDaemonPtyProviderInventory } from './daemon-pty-router-inventory'
|
||||
import { DaemonPtyExactOperationRouter } from './daemon-pty-router-exact-operations'
|
||||
|
||||
export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
export class DegradedDaemonPtyProvider
|
||||
extends DaemonPtyExactOperationRouter
|
||||
implements IPtyProvider
|
||||
{
|
||||
readonly isDegraded = true
|
||||
|
||||
private current: DaemonPtyAdapter
|
||||
@@ -39,6 +44,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
fallback: IPtyProvider
|
||||
probeCurrentDaemonSpawn?: () => Promise<boolean>
|
||||
}) {
|
||||
super()
|
||||
this.current = opts.current
|
||||
this.legacy = opts.legacy
|
||||
this.fallback = opts.fallback
|
||||
@@ -94,9 +100,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null
|
||||
}
|
||||
|
||||
async probePtyLiveness(id: string): Promise<boolean | null> {
|
||||
return await this.ownerRecovery.probe(id)
|
||||
}
|
||||
probePtyLiveness = (id: string): Promise<boolean | null> => this.ownerRecovery.probe(id)
|
||||
|
||||
// Why: an unknown id cannot borrow listing authority from the fresh-spawn provider.
|
||||
providesAgentSessionOwnerListings = (ptyId: string): boolean =>
|
||||
@@ -194,9 +198,16 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
|
||||
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
|
||||
const results = await Promise.all(
|
||||
this.allProviders().map((provider) => provider.listProcesses(opts))
|
||||
this.allProviders().map(async (provider) => ({
|
||||
provider,
|
||||
processes: await provider.listProcesses(opts)
|
||||
}))
|
||||
)
|
||||
return results.flat()
|
||||
return reconcileDaemonPtyProviderInventory(results, {
|
||||
existingRouteIds: this.sessionProviders.keys(),
|
||||
recordRoute: (id, owner) => this.ownerRecovery.recordRoute(id, owner),
|
||||
forgetRoute: (id) => this.ownerRecovery.forgetRoute(id)
|
||||
})
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
@@ -334,6 +345,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
)
|
||||
}
|
||||
|
||||
protected exactProviderFor(sessionId: string): IPtyProvider | null {
|
||||
return this.sessionProviders.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
private findProviderForExistingSession(sessionId: string): IPtyProvider | null {
|
||||
return adoptOwningProvider(this.sessionProviders, this.allProviders(), sessionId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PRODUCER_PAUSE_FAILSAFE_MS, Session, type SubprocessHandle } from './session'
|
||||
|
||||
function createSubprocess(): SubprocessHandle & {
|
||||
pauseCalls: number
|
||||
resumeCalls: number
|
||||
} {
|
||||
return {
|
||||
pid: 123,
|
||||
pauseCalls: 0,
|
||||
resumeCalls: 0,
|
||||
getForegroundProcess: () => null,
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
pause() {
|
||||
this.pauseCalls += 1
|
||||
},
|
||||
resume() {
|
||||
this.resumeCalls += 1
|
||||
},
|
||||
kill: () => {},
|
||||
forceKill: () => {},
|
||||
signal: () => {},
|
||||
onData: () => {},
|
||||
onExit: () => {},
|
||||
dispose: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Session exact held producer pause', () => {
|
||||
let session: Session
|
||||
let subprocess: ReturnType<typeof createSubprocess>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
subprocess = createSubprocess()
|
||||
session = new Session({
|
||||
sessionId: 'session',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
subprocess,
|
||||
shellReadySupported: false
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
session.dispose()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stays paused beyond the legacy failsafe until explicit release', () => {
|
||||
expect(session.supportsExactHeldProducerPause()).toBe(true)
|
||||
expect(session.acquireExactHeldProducerPause('client-a', 'lease-a')).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2)
|
||||
expect(subprocess.pauseCalls).toBe(1)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
|
||||
expect(session.releaseExactHeldProducerPause('client-a', 'lease-a')).toBe(true)
|
||||
expect(session.releaseExactHeldProducerPause('client-a', 'lease-a')).toBe(true)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('resumes only after every owner releases its idempotent lease', () => {
|
||||
expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(true)
|
||||
expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(true)
|
||||
expect(session.acquireExactHeldProducerPause('client-b', 'lease')).toBe(true)
|
||||
expect(subprocess.pauseCalls).toBe(1)
|
||||
|
||||
expect(session.releaseExactHeldProducerPause('client-a', 'lease')).toBe(true)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
expect(session.releaseExactHeldProducerPause('client-b', 'lease')).toBe(true)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps an exact hold through legacy failsafe expiry', () => {
|
||||
session.pauseProducer()
|
||||
expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS)
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
expect(session.releaseExactHeldProducerPause('client-a', 'lease')).toBe(true)
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('cleans up only the disconnected owner leases', () => {
|
||||
session.acquireExactHeldProducerPause('client-a', 'lease-a')
|
||||
session.acquireExactHeldProducerPause('client-b', 'lease-b')
|
||||
|
||||
session.releaseExactHeldProducerPauses('client-a')
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
session.releaseExactHeldProducerPauses('client-b')
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('does not let renderer reattachment implicitly release an exact lease', () => {
|
||||
session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
session.acquireExactHeldProducerPause('client-a', 'lease')
|
||||
|
||||
session.detachAllClients()
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
session.attachClient({ onData: () => {}, onExit: () => {} })
|
||||
expect(subprocess.resumeCalls).toBe(0)
|
||||
session.releaseExactHeldProducerPause('client-a', 'lease')
|
||||
expect(subprocess.resumeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('does not advertise a subprocess without native flow control', () => {
|
||||
session.dispose()
|
||||
const { pause: _pause, resume: _resume, ...unsupported } = createSubprocess()
|
||||
void _pause
|
||||
void _resume
|
||||
session = new Session({
|
||||
sessionId: 'unsupported',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
subprocess: unsupported,
|
||||
shellReadySupported: false
|
||||
})
|
||||
|
||||
expect(session.supportsExactHeldProducerPause()).toBe(false)
|
||||
expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -126,7 +126,10 @@ export class Session {
|
||||
private pendingOutputSeq = 0
|
||||
private outputSequence = 0
|
||||
private producerPaused = false
|
||||
private legacyProducerPauseActive = false
|
||||
private producerPauseFailsafeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private heldProducerPauseCount = 0
|
||||
private heldProducerPauseTokensByOwner = new Map<string, Set<string>>()
|
||||
private readonly _historySeeded: boolean | undefined
|
||||
private forceKillSent = false
|
||||
private subprocessDisposed = false
|
||||
@@ -254,6 +257,7 @@ export class Session {
|
||||
if (this._state === 'exited' || this._disposed) {
|
||||
return
|
||||
}
|
||||
this.legacyProducerPauseActive = true
|
||||
this.producerPaused = true
|
||||
this.subprocess.pause?.()
|
||||
if (this.producerPauseFailsafeTimer) {
|
||||
@@ -261,13 +265,97 @@ export class Session {
|
||||
}
|
||||
this.producerPauseFailsafeTimer = setTimeout(() => {
|
||||
this.producerPauseFailsafeTimer = null
|
||||
this.producerPaused = false
|
||||
this.subprocess.resume?.()
|
||||
this.legacyProducerPauseActive = false
|
||||
if (this.heldProducerPauseCount === 0) {
|
||||
this.producerPaused = false
|
||||
this.subprocess.resume?.()
|
||||
}
|
||||
}, PRODUCER_PAUSE_FAILSAFE_MS)
|
||||
}
|
||||
|
||||
resumeProducer(): void {
|
||||
this.releaseProducerPause({ resume: true })
|
||||
if (this.producerPauseFailsafeTimer) {
|
||||
clearTimeout(this.producerPauseFailsafeTimer)
|
||||
this.producerPauseFailsafeTimer = null
|
||||
}
|
||||
this.legacyProducerPauseActive = false
|
||||
if (this.heldProducerPauseCount === 0 && this.producerPaused) {
|
||||
this.producerPaused = false
|
||||
this.subprocess.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
supportsExactHeldProducerPause(): boolean {
|
||||
return (
|
||||
this._state !== 'exited' &&
|
||||
!this._disposed &&
|
||||
!this._isTerminating &&
|
||||
typeof this.subprocess.pause === 'function' &&
|
||||
typeof this.subprocess.resume === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
acquireExactHeldProducerPause(ownerId: string, token: string): boolean {
|
||||
if (!this.supportsExactHeldProducerPause()) {
|
||||
return false
|
||||
}
|
||||
const ownerTokens = this.heldProducerPauseTokensByOwner.get(ownerId)
|
||||
if (ownerTokens?.has(token)) {
|
||||
return true
|
||||
}
|
||||
if (this.heldProducerPauseCount === 0) {
|
||||
try {
|
||||
this.subprocess.pause!()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
this.producerPaused = true
|
||||
}
|
||||
const tokens = ownerTokens ?? new Set<string>()
|
||||
tokens.add(token)
|
||||
this.heldProducerPauseTokensByOwner.set(ownerId, tokens)
|
||||
this.heldProducerPauseCount += 1
|
||||
return true
|
||||
}
|
||||
|
||||
releaseExactHeldProducerPause(ownerId: string, token: string): boolean {
|
||||
const ownerTokens = this.heldProducerPauseTokensByOwner.get(ownerId)
|
||||
if (!ownerTokens?.has(token)) {
|
||||
return this._state !== 'exited' && !this._disposed
|
||||
}
|
||||
const isLastHeldPause = this.heldProducerPauseCount === 1
|
||||
if (isLastHeldPause && !this.legacyProducerPauseActive) {
|
||||
try {
|
||||
this.subprocess.resume?.()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
this.producerPaused = false
|
||||
}
|
||||
ownerTokens.delete(token)
|
||||
this.heldProducerPauseCount -= 1
|
||||
if (ownerTokens.size === 0) {
|
||||
this.heldProducerPauseTokensByOwner.delete(ownerId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
releaseExactHeldProducerPauses(ownerId: string): void {
|
||||
const ownerTokens = this.heldProducerPauseTokensByOwner.get(ownerId)
|
||||
if (!ownerTokens) {
|
||||
return
|
||||
}
|
||||
this.heldProducerPauseTokensByOwner.delete(ownerId)
|
||||
this.heldProducerPauseCount = Math.max(0, this.heldProducerPauseCount - ownerTokens.size)
|
||||
if (this.heldProducerPauseCount > 0 || this.legacyProducerPauseActive) {
|
||||
return
|
||||
}
|
||||
this.producerPaused = false
|
||||
try {
|
||||
this.subprocess.resume?.()
|
||||
} catch {
|
||||
/* Connection cleanup cannot retry a torn-down handle. */
|
||||
}
|
||||
}
|
||||
|
||||
private releaseProducerPause(opts: { resume: boolean }): void {
|
||||
@@ -275,6 +363,9 @@ export class Session {
|
||||
clearTimeout(this.producerPauseFailsafeTimer)
|
||||
this.producerPauseFailsafeTimer = null
|
||||
}
|
||||
this.legacyProducerPauseActive = false
|
||||
this.heldProducerPauseTokensByOwner.clear()
|
||||
this.heldProducerPauseCount = 0
|
||||
if (!this.producerPaused) {
|
||||
return
|
||||
}
|
||||
@@ -393,15 +484,15 @@ export class Session {
|
||||
if (idx !== -1) {
|
||||
this.attachedClients.splice(idx, 1)
|
||||
}
|
||||
// Why: with no attached client nobody will send resumePty, so a paused shell would wedge until the failsafe; resume eagerly.
|
||||
// Why: exact leases are connection-owned; detach only retires the legacy failsafe pause.
|
||||
if (this.attachedClients.length === 0) {
|
||||
this.releaseProducerPause({ resume: true })
|
||||
this.resumeProducer()
|
||||
}
|
||||
}
|
||||
|
||||
detachAllClients(): void {
|
||||
this.attachedClients.length = 0
|
||||
this.releaseProducerPause({ resume: true })
|
||||
this.resumeProducer()
|
||||
}
|
||||
|
||||
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CreateOrAttachOptions } from './terminal-host-create-contract'
|
||||
import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
|
||||
import type { TerminalAuthorityPolicyConsumerSource } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
|
||||
export function requireTerminalHostAuthorityConsumerSource(
|
||||
options: CreateOrAttachOptions,
|
||||
ptyOwnerAvailable: boolean
|
||||
): TerminalAuthorityPolicyConsumerSource {
|
||||
const policyConsumer = options.terminalSessionAuthorityPolicyConsumer
|
||||
if (!ptyOwnerAvailable || !policyConsumer) {
|
||||
throw new Error('terminal_session_authority_outcome_consumer_required')
|
||||
}
|
||||
return policyConsumer
|
||||
}
|
||||
|
||||
export function authorityTerminalStreamClient(
|
||||
options: InternalCreateOrAttachOptions,
|
||||
ptyOwnerAvailable: boolean
|
||||
): InternalCreateOrAttachOptions['streamClient'] {
|
||||
requireTerminalHostAuthorityConsumerSource(options, ptyOwnerAvailable)
|
||||
return { ...options.streamClient, onExit: () => undefined }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES = 100 * 1024
|
||||
export const TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_FRAMES = 1_024
|
||||
|
||||
export type TerminalHostBufferedOutput = Readonly<{
|
||||
data: string
|
||||
rawLength?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}>
|
||||
|
||||
export class TerminalHostAuthorityOutputBuffer {
|
||||
private readonly frames: TerminalHostBufferedOutput[] = []
|
||||
private bytes = 0
|
||||
private overflowed = false
|
||||
|
||||
append(frame: TerminalHostBufferedOutput): boolean {
|
||||
const bytes = Buffer.byteLength(frame.data)
|
||||
if (
|
||||
this.overflowed ||
|
||||
this.frames.length >= TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_FRAMES ||
|
||||
this.bytes + bytes > TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES
|
||||
) {
|
||||
this.overflowed = true
|
||||
return false
|
||||
}
|
||||
this.frames.push(Object.freeze({ ...frame }))
|
||||
this.bytes += bytes
|
||||
return true
|
||||
}
|
||||
|
||||
drain(): readonly TerminalHostBufferedOutput[] {
|
||||
const drained = this.frames.splice(0)
|
||||
this.bytes = 0
|
||||
return drained
|
||||
}
|
||||
|
||||
get exceededCapacity(): boolean {
|
||||
return this.overflowed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class TerminalHostAuthorityRequestQueue {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
enqueue<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(sessionId) ?? Promise.resolve()
|
||||
const result = previous.then(request)
|
||||
const settled = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
this.queues.set(sessionId, settled)
|
||||
void settled.then(() => {
|
||||
if (this.queues.get(sessionId) === settled) {
|
||||
this.queues.delete(sessionId)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
settlement(): Promise<void> | null {
|
||||
return this.queues.size > 0
|
||||
? Promise.allSettled(this.queues.values()).then(() => undefined)
|
||||
: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Session } from './session'
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
import {
|
||||
sameTerminalSessionAuthorityPtyAccess,
|
||||
type TerminalSessionAuthorityPtyAccess
|
||||
} from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation'
|
||||
|
||||
export class TerminalHostAuthoritySemanticRecorder {
|
||||
constructor(
|
||||
private readonly sessions: ReadonlyMap<string, Session>,
|
||||
private readonly ptyOwner: TerminalSessionAuthorityPtyOwner | null,
|
||||
private readonly accessFor: (sessionId: string) => TerminalSessionAuthorityPtyAccess | null,
|
||||
private readonly fail: (error: unknown) => void
|
||||
) {}
|
||||
|
||||
async recordExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
fact: TerminalSessionAuthoritySemanticFact
|
||||
): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (
|
||||
!session?.isAlive ||
|
||||
access.binding.physicalPtyId !== sessionId ||
|
||||
access.binding.ptyIncarnationId !== session.incarnationId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const currentAccess = this.accessFor(sessionId)
|
||||
if (!currentAccess) {
|
||||
const error = new Error('terminal_session_authority_semantic_access_missing')
|
||||
this.fail(error)
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
!sameTerminalSessionAuthorityPtyAccess(currentAccess, access) ||
|
||||
!this.ptyOwner?.admits(sessionId, access)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return await this.ptyOwner.recordSemanticOutcome(sessionId, access, fact)
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import { sameTerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES } from './terminal-host-authority-output-buffer'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import type { TerminalAuthorityPolicyConsumerConnection } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
|
||||
type TestSubprocess = SubprocessHandle & {
|
||||
emitData(data: string): void
|
||||
emitExit(code: number): void
|
||||
}
|
||||
|
||||
const POLICY_CONSUMER = Object.freeze({
|
||||
identity: Object.freeze({
|
||||
consumerId: 'app-profile:host-test',
|
||||
consumerIncarnationId: 'app-process:host-test'
|
||||
}),
|
||||
activate: async () => {},
|
||||
ensureNamespace: async () => {},
|
||||
assertInstalled: () => {},
|
||||
acknowledge: async (ack) => ack.sequence,
|
||||
retire: async () => 0,
|
||||
isInstalled: () => true,
|
||||
disconnect: () => {}
|
||||
}) satisfies TerminalAuthorityPolicyConsumerConnection
|
||||
|
||||
function createSubprocess(events: string[]): TestSubprocess {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
let onExit: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 99_999,
|
||||
getForegroundProcess: () => null,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(() => events.push('pause')),
|
||||
resume: vi.fn(() => events.push('resume')),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: (callback) => {
|
||||
onData = callback
|
||||
},
|
||||
onExit: (callback) => {
|
||||
onExit = callback
|
||||
},
|
||||
dispose: vi.fn(),
|
||||
emitData: (data) => onData?.(data),
|
||||
emitExit: (code) => onExit?.(code)
|
||||
}
|
||||
}
|
||||
|
||||
function access(ptyIncarnationId: string): TerminalSessionAuthorityPtyAccess {
|
||||
return {
|
||||
namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' },
|
||||
pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' },
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-a',
|
||||
physicalPtyId: 'pty-a',
|
||||
ptyIncarnationId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function authorityFixture(events: string[], onCommit?: (subprocess: TestSubprocess) => void) {
|
||||
const subprocess = createSubprocess(events)
|
||||
let currentAccess: TerminalSessionAuthorityPtyAccess | null = null
|
||||
const owner = {
|
||||
prepareSpawn: vi.fn(async () => {
|
||||
events.push('durable-prepare')
|
||||
return { kind: 'spawn', prepared: { kind: 'spawn' } }
|
||||
}),
|
||||
commitSpawn: vi.fn(async (_prepared, incarnationId: string) => {
|
||||
events.push('durable-commit')
|
||||
onCommit?.(subprocess)
|
||||
currentAccess = access(incarnationId)
|
||||
return currentAccess
|
||||
}),
|
||||
cancelSpawn: vi.fn(async () => events.push('cancel-prepare')),
|
||||
accessFor: vi.fn((id: string) => (id === 'pty-a' ? currentAccess : null)),
|
||||
admits: vi.fn(
|
||||
(id: string, candidate: TerminalSessionAuthorityPtyAccess) =>
|
||||
id === 'pty-a' && sameTerminalSessionAuthorityPtyAccess(currentAccess, candidate)
|
||||
),
|
||||
recordExit: vi.fn(async () => {
|
||||
currentAccess = null
|
||||
}),
|
||||
policyConsumerTransportInstalled: vi.fn(() => true),
|
||||
recordSemanticOutcome: vi.fn(
|
||||
async (_id: string, candidate: TerminalSessionAuthorityPtyAccess) =>
|
||||
sameTerminalSessionAuthorityPtyAccess(currentAccess, candidate)
|
||||
),
|
||||
close: vi.fn(async (_id: string, candidate: TerminalSessionAuthorityPtyAccess) => {
|
||||
if (!sameTerminalSessionAuthorityPtyAccess(currentAccess, candidate)) {
|
||||
return false
|
||||
}
|
||||
currentAccess = null
|
||||
return true
|
||||
}),
|
||||
adopt: vi.fn()
|
||||
} as unknown as TerminalSessionAuthorityPtyOwner
|
||||
return { owner, subprocess }
|
||||
}
|
||||
|
||||
function createOptions(publishAuthorityExit = vi.fn(async () => undefined)) {
|
||||
return {
|
||||
sessionId: 'pty-a',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo::/srv/repo',
|
||||
paneKey: 'pane-a',
|
||||
paneGeneration: 3,
|
||||
terminalSessionAuthorityVersion: 1,
|
||||
terminalSessionAuthorityOperationId: 'spawn-operation-a',
|
||||
terminalSessionAuthorityNegotiated: true as const,
|
||||
terminalSessionAuthorityPolicyConsumer: POLICY_CONSUMER,
|
||||
streamClient: {
|
||||
onIncarnation: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
publishAuthorityExit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('TerminalHost authority session transaction', () => {
|
||||
it('commits and installs the outcome route before releasing bounded early output', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events, (subprocess) => subprocess.emitData('boot\n'))
|
||||
const options = createOptions()
|
||||
options.streamClient.onData.mockImplementation(() => events.push('publish-data'))
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => {
|
||||
events.push('native-spawn')
|
||||
return fixture.subprocess
|
||||
},
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
}
|
||||
})
|
||||
|
||||
const result = await host.createOrAttach(options)
|
||||
|
||||
expect(events).toEqual([
|
||||
'durable-prepare',
|
||||
'native-spawn',
|
||||
'pause',
|
||||
'durable-commit',
|
||||
'publish-data',
|
||||
'resume'
|
||||
])
|
||||
expect(result.terminalSessionAuthorityAccess).toEqual(access(result.incarnationId))
|
||||
expect(options.streamClient.onData).toHaveBeenCalledWith('boot\n')
|
||||
expect(host.listSessions()[0]?.terminalSessionAuthorityAccess).toEqual(
|
||||
result.terminalSessionAuthorityAccess
|
||||
)
|
||||
|
||||
fixture.subprocess.emitExit(17)
|
||||
await vi.waitFor(() => expect(fixture.owner.recordExit).toHaveBeenCalled())
|
||||
expect(options.streamClient.onExit).not.toHaveBeenCalled()
|
||||
expect(host.listSessions()).toEqual([])
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('rejects legacy and stale mutations once full authority is installed', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events)
|
||||
const options = createOptions()
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => fixture.subprocess,
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
}
|
||||
})
|
||||
const result = await host.createOrAttach(options)
|
||||
const authorityAccess = result.terminalSessionAuthorityAccess!
|
||||
|
||||
expect(host.writeExact('pty-a', result.incarnationId, 'legacy')).toBe(false)
|
||||
expect(() => host.write('pty-a', 'legacy')).toThrow('Session not found')
|
||||
expect(host.writeAuthorityExact('pty-a', authorityAccess, 'current')).toBe(true)
|
||||
expect(host.writeAuthorityExact('pty-a', access('stale-incarnation'), 'stale')).toBe(false)
|
||||
expect(fixture.subprocess.write).toHaveBeenCalledWith('current')
|
||||
expect(fixture.subprocess.write).not.toHaveBeenCalledWith('legacy')
|
||||
expect(fixture.subprocess.write).not.toHaveBeenCalledWith('stale')
|
||||
|
||||
await expect(host.killAuthorityExact('pty-a', authorityAccess, POLICY_CONSUMER)).resolves.toBe(
|
||||
true
|
||||
)
|
||||
await vi.waitFor(() => expect(host.listSessions()).toEqual([]))
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('records a semantic fact only against its captured full binding', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events)
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => fixture.subprocess,
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
}
|
||||
})
|
||||
const result = await host.createOrAttach(createOptions())
|
||||
const current = result.terminalSessionAuthorityAccess!
|
||||
const stale = {
|
||||
...current,
|
||||
pane: { ...current.pane, paneGenerationId: 'renderer:stale' }
|
||||
}
|
||||
|
||||
await expect(host.recordSemanticOutcomeExact('pty-a', current, { kind: 'bell' })).resolves.toBe(
|
||||
true
|
||||
)
|
||||
await expect(host.recordSemanticOutcomeExact('pty-a', stale, { kind: 'bell' })).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(fixture.owner.recordSemanticOutcome).toHaveBeenCalledOnce()
|
||||
expect(fixture.owner.recordSemanticOutcome).toHaveBeenCalledWith('pty-a', current, {
|
||||
kind: 'bell'
|
||||
})
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('fails closed when an alive session has no captured authority binding', async () => {
|
||||
const subprocess = createSubprocess([])
|
||||
const onFailure = vi.fn()
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => subprocess,
|
||||
onTerminalSessionAuthorityFailure: onFailure
|
||||
})
|
||||
const options = {
|
||||
sessionId: 'pty-a',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: {
|
||||
onIncarnation: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
}
|
||||
const result = await host.createOrAttach(options)
|
||||
|
||||
await expect(
|
||||
host.recordSemanticOutcomeExact('pty-a', access(result.incarnationId), { kind: 'bell' })
|
||||
).rejects.toThrow('terminal_session_authority_semantic_access_missing')
|
||||
expect(onFailure).toHaveBeenCalledOnce()
|
||||
await expect(host.dispose()).rejects.toThrow(
|
||||
'terminal_session_authority_semantic_access_missing'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects authority metadata before physical spawn when hello admission is absent', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events)
|
||||
const spawnSubprocess = vi.fn(() => fixture.subprocess)
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess,
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
}
|
||||
})
|
||||
const { terminalSessionAuthorityNegotiated: _admission, ...unnegotiated } = createOptions()
|
||||
|
||||
await expect(host.createOrAttach(unnegotiated)).rejects.toThrow(
|
||||
'terminal_session_authority_unavailable'
|
||||
)
|
||||
expect(fixture.owner.prepareSpawn).not.toHaveBeenCalled()
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('rejects admitted metadata before physical spawn when authority infrastructure is absent', async () => {
|
||||
const spawnSubprocess = vi.fn(() => createSubprocess([]))
|
||||
const host = new TerminalHost({ spawnSubprocess })
|
||||
|
||||
await expect(host.createOrAttach(createOptions())).rejects.toThrow(
|
||||
'terminal_session_authority_unavailable'
|
||||
)
|
||||
expect(spawnSubprocess).not.toHaveBeenCalled()
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('publishes an exit that races durable commit only after the session is addressable', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events, (subprocess) => {
|
||||
subprocess.emitData('last output\n')
|
||||
subprocess.emitExit(23)
|
||||
})
|
||||
const options = createOptions()
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => fixture.subprocess,
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
}
|
||||
})
|
||||
|
||||
await host.createOrAttach(options)
|
||||
|
||||
await vi.waitFor(() => expect(fixture.owner.recordExit).toHaveBeenCalled())
|
||||
expect(options.streamClient.onData).toHaveBeenCalledWith('last output\n')
|
||||
expect(options.streamClient.onExit).not.toHaveBeenCalled()
|
||||
expect(fixture.subprocess.resume).not.toHaveBeenCalled()
|
||||
expect(host.listSessions()).toEqual([])
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('cancels the durable allocation only when native spawn never dispatches', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events)
|
||||
const spawnFailure = new Error('native spawn failed')
|
||||
const onFailure = vi.fn()
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => {
|
||||
throw spawnFailure
|
||||
},
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
},
|
||||
onTerminalSessionAuthorityFailure: onFailure
|
||||
})
|
||||
|
||||
await expect(host.createOrAttach(createOptions())).rejects.toThrow(spawnFailure)
|
||||
expect(fixture.owner.cancelSpawn).toHaveBeenCalledOnce()
|
||||
expect(onFailure).not.toHaveBeenCalled()
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('fences creation instead of releasing an over-capacity pre-commit stream', async () => {
|
||||
const events: string[] = []
|
||||
const fixture = authorityFixture(events, (subprocess) =>
|
||||
subprocess.emitData('x'.repeat(TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES + 1))
|
||||
)
|
||||
const onFailure = vi.fn()
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => fixture.subprocess,
|
||||
terminalSessionAuthority: {
|
||||
ptyOwner: fixture.owner
|
||||
},
|
||||
onTerminalSessionAuthorityFailure: onFailure
|
||||
})
|
||||
|
||||
await expect(host.createOrAttach(createOptions())).rejects.toThrow(
|
||||
'terminal_session_authority_early_output_capacity_exceeded'
|
||||
)
|
||||
expect(fixture.owner.cancelSpawn).not.toHaveBeenCalled()
|
||||
expect(fixture.subprocess.resume).not.toHaveBeenCalled()
|
||||
expect(onFailure).toHaveBeenCalledOnce()
|
||||
await expect(host.dispose()).rejects.toThrow(
|
||||
'terminal_session_authority_early_output_capacity_exceeded'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { Session } from './session'
|
||||
import {
|
||||
createOrAttachRequestsTerminalSessionAuthority,
|
||||
type CreateOrAttachResult
|
||||
} from './terminal-host-create-contract'
|
||||
import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
|
||||
import type { TerminalHostSessionCreationHooks } from './terminal-host-session-create'
|
||||
import type { TerminalHostOptions } from './terminal-host-options'
|
||||
import {
|
||||
parseTerminalSessionAuthorityPtyAccess,
|
||||
sameTerminalSessionAuthorityPtyAccess,
|
||||
type TerminalSessionAuthorityPtyAccess
|
||||
} from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation'
|
||||
import type { TerminalAuthorityPreparedPtySpawn } from '../session-authority/terminal-session-authority-pty-binding'
|
||||
import { TerminalHostAuthoritySemanticRecorder } from './terminal-host-authority-semantic-recorder'
|
||||
import { TerminalHostAuthorityRequestQueue } from './terminal-host-authority-request-queue'
|
||||
import { spawnTerminalHostAuthoritySession } from './terminal-host-authority-spawn'
|
||||
import {
|
||||
authorityTerminalStreamClient,
|
||||
requireTerminalHostAuthorityConsumerSource
|
||||
} from './terminal-host-authority-consumer-routing'
|
||||
import {
|
||||
terminalAuthorityPolicyConsumerForNamespace,
|
||||
type TerminalAuthorityPolicyConsumerConnection
|
||||
} from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
|
||||
type AuthorityOptions = NonNullable<TerminalHostOptions['terminalSessionAuthority']>
|
||||
|
||||
export type TerminalHostAuthoritySessionDependencies = Readonly<{
|
||||
sessions: ReadonlyMap<string, Session>
|
||||
createPhysical(
|
||||
options: InternalCreateOrAttachOptions,
|
||||
creationHooks?: TerminalHostSessionCreationHooks
|
||||
): Promise<CreateOrAttachResult>
|
||||
releaseExited(sessionId: string, generation: string | undefined): void
|
||||
fenceCreation(): void
|
||||
}>
|
||||
|
||||
export class TerminalHostAuthoritySessions {
|
||||
private readonly ptyOwner: AuthorityOptions['ptyOwner'] | null
|
||||
private readonly accessBySession = new Map<string, TerminalSessionAuthorityPtyAccess>()
|
||||
private readonly exitFinalizations = new Set<Promise<void>>()
|
||||
private readonly pendingSessions = new Set<Session>()
|
||||
private readonly requestQueue = new TerminalHostAuthorityRequestQueue()
|
||||
private readonly semanticRecorder: TerminalHostAuthoritySemanticRecorder
|
||||
private failure: Error | null = null
|
||||
|
||||
constructor(
|
||||
options: TerminalHostOptions['terminalSessionAuthority'],
|
||||
private readonly onFailure: ((error: Error) => void) | undefined,
|
||||
private readonly deps: TerminalHostAuthoritySessionDependencies
|
||||
) {
|
||||
this.ptyOwner = options?.ptyOwner ?? null
|
||||
this.semanticRecorder = new TerminalHostAuthoritySemanticRecorder(
|
||||
deps.sessions,
|
||||
this.ptyOwner,
|
||||
(sessionId) => this.accessFor(sessionId),
|
||||
(error) => this.fail(error)
|
||||
)
|
||||
}
|
||||
|
||||
enqueue<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
|
||||
return this.requestQueue.enqueue(sessionId, request)
|
||||
}
|
||||
|
||||
async createOrAttach(options: InternalCreateOrAttachOptions): Promise<CreateOrAttachResult> {
|
||||
const authorityRequested = createOrAttachRequestsTerminalSessionAuthority(options)
|
||||
if (authorityRequested && options.terminalSessionAuthorityNegotiated !== true) {
|
||||
throw new Error('terminal_session_authority_unavailable')
|
||||
}
|
||||
if (!this.ptyOwner) {
|
||||
if (authorityRequested) {
|
||||
throw new Error('terminal_session_authority_unavailable')
|
||||
}
|
||||
options.streamClient.onAuthorityAccess?.(null)
|
||||
return await this.deps.createPhysical(options)
|
||||
}
|
||||
const suppliedAccess = this.parseSuppliedAccess(options)
|
||||
if (suppliedAccess) {
|
||||
return await this.attach(options, suppliedAccess)
|
||||
}
|
||||
if (
|
||||
options.terminalSessionAuthorityVersion !== undefined ||
|
||||
options.terminalSessionAuthorityOperationId !== undefined
|
||||
) {
|
||||
return await this.createOrAdopt(options)
|
||||
}
|
||||
if (this.accessFor(options.sessionId)) {
|
||||
throw new Error('terminal_session_authority_access_required')
|
||||
}
|
||||
options.streamClient.onAuthorityAccess?.(null)
|
||||
return await this.deps.createPhysical(options)
|
||||
}
|
||||
|
||||
accessFor(sessionId: string): TerminalSessionAuthorityPtyAccess | null {
|
||||
return this.accessBySession.get(sessionId) ?? this.ptyOwner?.accessFor(sessionId) ?? null
|
||||
}
|
||||
|
||||
exactSession(sessionId: string, access: TerminalSessionAuthorityPtyAccess): Session | null {
|
||||
const parsed = parseTerminalSessionAuthorityPtyAccess(access)
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
if (
|
||||
!parsed ||
|
||||
!session?.isAlive ||
|
||||
parsed.binding.physicalPtyId !== sessionId ||
|
||||
parsed.binding.ptyIncarnationId !== session.incarnationId ||
|
||||
!sameTerminalSessionAuthorityPtyAccess(this.accessFor(sessionId), parsed) ||
|
||||
!this.ptyOwner?.admits(sessionId, parsed)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
exactRememberedSession(access: TerminalSessionAuthorityPtyAccess): Session | null {
|
||||
const sessionId = access.binding.physicalPtyId
|
||||
const remembered = this.accessBySession.get(sessionId)
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
return remembered &&
|
||||
session?.isAlive &&
|
||||
session.incarnationId === access.binding.ptyIncarnationId &&
|
||||
sameTerminalSessionAuthorityPtyAccess(remembered, access)
|
||||
? session
|
||||
: null
|
||||
}
|
||||
|
||||
async closeExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
policyConsumer: TerminalAuthorityPolicyConsumerConnection
|
||||
): Promise<boolean> {
|
||||
if (!this.exactSession(sessionId, access)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return await this.ptyOwner!.close(sessionId, access, policyConsumer)
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async recordSemanticOutcomeExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
fact: TerminalSessionAuthoritySemanticFact
|
||||
): Promise<boolean> {
|
||||
return await this.semanticRecorder.recordExact(sessionId, access, fact)
|
||||
}
|
||||
|
||||
handleExit(sessionId: string, generation: string | undefined, code: number): void {
|
||||
const access = this.accessFor(sessionId)
|
||||
if (!access) {
|
||||
this.deps.releaseExited(sessionId, generation)
|
||||
return
|
||||
}
|
||||
const finalization = this.finalizeExit(sessionId, generation, access, code)
|
||||
this.exitFinalizations.add(finalization)
|
||||
void finalization.then(
|
||||
() => this.exitFinalizations.delete(finalization),
|
||||
(error) => {
|
||||
this.exitFinalizations.delete(finalization)
|
||||
this.fail(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
requestSettlement(): Promise<void> | null {
|
||||
return this.requestQueue.settlement()
|
||||
}
|
||||
|
||||
unpublishedSessions(): readonly Session[] {
|
||||
return [...this.pendingSessions].filter((session) => !this.deps.sessions.has(session.sessionId))
|
||||
}
|
||||
|
||||
async finishDispose(): Promise<void> {
|
||||
await Promise.all(this.exitFinalizations)
|
||||
if (this.failure) {
|
||||
throw this.failure
|
||||
}
|
||||
this.accessBySession.clear()
|
||||
this.pendingSessions.clear()
|
||||
}
|
||||
|
||||
private parseSuppliedAccess(
|
||||
options: InternalCreateOrAttachOptions
|
||||
): TerminalSessionAuthorityPtyAccess | null {
|
||||
const supplied = options.terminalSessionAuthorityAccess
|
||||
const parsed = supplied === undefined ? null : parseTerminalSessionAuthorityPtyAccess(supplied)
|
||||
if (supplied !== undefined && !parsed) {
|
||||
throw new Error('terminal_session_authority_access_invalid')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
private async createOrAdopt(
|
||||
options: InternalCreateOrAttachOptions
|
||||
): Promise<CreateOrAttachResult> {
|
||||
const existing = this.deps.sessions.get(options.sessionId)
|
||||
if (existing?.isAlive && !this.accessFor(options.sessionId)) {
|
||||
throw new Error('terminal_session_authority_binding_required')
|
||||
}
|
||||
const policyConsumer = requireTerminalHostAuthorityConsumerSource(
|
||||
options,
|
||||
this.ptyOwner !== null
|
||||
)
|
||||
const preparation = await this.ptyOwner!.prepareSpawn(
|
||||
options,
|
||||
options.sessionId,
|
||||
policyConsumer,
|
||||
options.terminalSessionAuthorityOperationId
|
||||
)
|
||||
if (preparation.kind === 'spawn') {
|
||||
return await this.spawn(options, preparation.prepared)
|
||||
}
|
||||
const { binding } = preparation.adopted
|
||||
if (binding.physicalPtyId !== options.sessionId) {
|
||||
throw new Error('terminal_session_authority_adopted_pty_mismatch')
|
||||
}
|
||||
const access = this.ptyOwner!.adopt(
|
||||
preparation.adopted,
|
||||
binding.physicalPtyId,
|
||||
binding.ptyIncarnationId
|
||||
)
|
||||
if (!access) {
|
||||
throw new Error('terminal_session_authority_live_binding_missing')
|
||||
}
|
||||
return await this.attach(options, access)
|
||||
}
|
||||
|
||||
private async attach(
|
||||
options: InternalCreateOrAttachOptions,
|
||||
access: TerminalSessionAuthorityPtyAccess
|
||||
): Promise<CreateOrAttachResult> {
|
||||
const policyConsumer = terminalAuthorityPolicyConsumerForNamespace(
|
||||
requireTerminalHostAuthorityConsumerSource(options, this.ptyOwner !== null),
|
||||
access.namespace
|
||||
)
|
||||
policyConsumer.assertInstalled(access.namespace)
|
||||
if (
|
||||
access.binding.physicalPtyId !== options.sessionId ||
|
||||
!this.ptyOwner!.admits(options.sessionId, access)
|
||||
) {
|
||||
throw new Error('terminal_session_authority_access_rejected')
|
||||
}
|
||||
options.streamClient.onAuthorityAccess?.(access)
|
||||
const result = await this.deps.createPhysical({
|
||||
...options,
|
||||
attachOnly: true,
|
||||
streamClient: authorityTerminalStreamClient(options, this.ptyOwner !== null)
|
||||
})
|
||||
if (result.incarnationId !== access.binding.ptyIncarnationId) {
|
||||
throw new Error('terminal_session_authority_incarnation_mismatch')
|
||||
}
|
||||
this.accessBySession.set(options.sessionId, access)
|
||||
return { ...result, terminalSessionAuthorityAccess: access }
|
||||
}
|
||||
|
||||
private async spawn(
|
||||
options: InternalCreateOrAttachOptions,
|
||||
prepared: TerminalAuthorityPreparedPtySpawn
|
||||
): Promise<CreateOrAttachResult> {
|
||||
return await spawnTerminalHostAuthoritySession({
|
||||
options,
|
||||
prepared,
|
||||
ptyOwner: this.ptyOwner!,
|
||||
dependencies: this.deps,
|
||||
accessBySession: this.accessBySession,
|
||||
pendingSessions: this.pendingSessions,
|
||||
fail: (error) => this.fail(error)
|
||||
})
|
||||
}
|
||||
|
||||
private async finalizeExit(
|
||||
sessionId: string,
|
||||
generation: string | undefined,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
code: number
|
||||
): Promise<void> {
|
||||
await this.ptyOwner!.recordExit(sessionId, access.binding.ptyIncarnationId, code)
|
||||
if (!sameTerminalSessionAuthorityPtyAccess(this.accessFor(sessionId), access)) {
|
||||
return
|
||||
}
|
||||
this.accessBySession.delete(sessionId)
|
||||
this.deps.releaseExited(sessionId, generation)
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
this.deps.fenceCreation()
|
||||
this.failure ??= normalized
|
||||
this.onFailure?.(normalized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { Session } from './session'
|
||||
import { TerminalHostAuthoritySubprocessGate } from './terminal-host-authority-subprocess-gate'
|
||||
import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
|
||||
import type { TerminalHostAuthoritySessionDependencies } from './terminal-host-authority-sessions'
|
||||
import type { TerminalHostSessionCreationHooks } from './terminal-host-session-create'
|
||||
import type { CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
import type { TerminalAuthorityPreparedPtySpawn } from '../session-authority/terminal-session-authority-pty-binding'
|
||||
import { authorityTerminalStreamClient } from './terminal-host-authority-consumer-routing'
|
||||
|
||||
export async function spawnTerminalHostAuthoritySession(args: {
|
||||
options: InternalCreateOrAttachOptions
|
||||
prepared: TerminalAuthorityPreparedPtySpawn
|
||||
ptyOwner: TerminalSessionAuthorityPtyOwner
|
||||
dependencies: TerminalHostAuthoritySessionDependencies
|
||||
accessBySession: Map<string, TerminalSessionAuthorityPtyAccess>
|
||||
pendingSessions: Set<Session>
|
||||
fail: (error: unknown) => void
|
||||
}): Promise<CreateOrAttachResult> {
|
||||
let gate: TerminalHostAuthoritySubprocessGate | null = null
|
||||
const creationHooks: TerminalHostSessionCreationHooks = {
|
||||
wrapSubprocess: (subprocess) => {
|
||||
gate = new TerminalHostAuthoritySubprocessGate(subprocess)
|
||||
return gate
|
||||
},
|
||||
onSpawnFailure: async () => {
|
||||
try {
|
||||
await args.ptyOwner.cancelSpawn(args.prepared)
|
||||
} catch (error) {
|
||||
args.fail(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
beforePublish: async (session) => {
|
||||
args.pendingSessions.add(session)
|
||||
const access = await args.ptyOwner.commitSpawn(args.prepared, session.incarnationId)
|
||||
if (!gate || gate.exceededCapacity) {
|
||||
throw new Error('terminal_session_authority_early_output_capacity_exceeded')
|
||||
}
|
||||
args.accessBySession.set(args.options.sessionId, access)
|
||||
args.options.streamClient.onAuthorityAccess?.(access)
|
||||
return access
|
||||
},
|
||||
afterPublish: (session) => {
|
||||
args.pendingSessions.delete(session)
|
||||
gate!.release()
|
||||
},
|
||||
onPostDispatchFailure: (error) => {
|
||||
gate?.releaseForShutdown()
|
||||
args.fail(error)
|
||||
}
|
||||
}
|
||||
return await args.dependencies.createPhysical(
|
||||
{
|
||||
...args.options,
|
||||
streamClient: authorityTerminalStreamClient(args.options, true)
|
||||
},
|
||||
creationHooks
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { TerminalHostAuthorityOutputBuffer } from './terminal-host-authority-output-buffer'
|
||||
|
||||
export class TerminalHostAuthoritySubprocessGate implements SubprocessHandle {
|
||||
readonly pid: number
|
||||
readonly startupCommandDeliveredInShellArgs: boolean | undefined
|
||||
readonly shellPath: string | undefined
|
||||
readonly slavePath: string | undefined
|
||||
private readonly output = new TerminalHostAuthorityOutputBuffer()
|
||||
private dataConsumer: ((data: string) => void) | null = null
|
||||
private exitConsumer: ((code: number) => void) | null = null
|
||||
private exitCode: number | undefined
|
||||
private released = false
|
||||
|
||||
constructor(private readonly subprocess: SubprocessHandle) {
|
||||
this.pid = subprocess.pid
|
||||
this.startupCommandDeliveredInShellArgs = subprocess.startupCommandDeliveredInShellArgs
|
||||
this.shellPath = subprocess.shellPath
|
||||
this.slavePath = subprocess.slavePath
|
||||
subprocess.pause?.()
|
||||
subprocess.onData((data) => {
|
||||
if (this.released) {
|
||||
this.dataConsumer?.(data)
|
||||
return
|
||||
}
|
||||
this.output.append({ data })
|
||||
})
|
||||
subprocess.onExit((code) => {
|
||||
if (this.released) {
|
||||
this.exitConsumer?.(code)
|
||||
return
|
||||
}
|
||||
this.exitCode ??= code
|
||||
})
|
||||
}
|
||||
|
||||
get exceededCapacity(): boolean {
|
||||
return this.output.exceededCapacity
|
||||
}
|
||||
|
||||
get exitedBeforeRelease(): boolean {
|
||||
return this.exitCode !== undefined
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.released) {
|
||||
return
|
||||
}
|
||||
if (this.output.exceededCapacity) {
|
||||
throw new Error('terminal_session_authority_early_output_capacity_exceeded')
|
||||
}
|
||||
this.released = true
|
||||
for (const frame of this.output.drain()) {
|
||||
this.dataConsumer?.(frame.data)
|
||||
}
|
||||
if (this.exitCode !== undefined) {
|
||||
this.exitConsumer?.(this.exitCode)
|
||||
} else {
|
||||
this.subprocess.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
releaseForShutdown(): void {
|
||||
if (this.released) {
|
||||
return
|
||||
}
|
||||
this.released = true
|
||||
this.output.drain()
|
||||
if (this.exitCode !== undefined) {
|
||||
this.exitConsumer?.(this.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
getForegroundProcess(): string | null {
|
||||
return this.subprocess.getForegroundProcess()
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(): Promise<string | null> {
|
||||
return await Promise.resolve(
|
||||
this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess()
|
||||
)
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.subprocess.write(data)
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.subprocess.resize(cols, rows)
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.subprocess.pause?.()
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.released) {
|
||||
this.subprocess.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.subprocess.clear?.()
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
this.subprocess.kill()
|
||||
}
|
||||
|
||||
forceKill(): void {
|
||||
this.subprocess.forceKill()
|
||||
}
|
||||
|
||||
signal(sig: string): void {
|
||||
this.subprocess.signal(sig)
|
||||
}
|
||||
|
||||
onData(cb: (data: string) => void): void {
|
||||
this.dataConsumer = cb
|
||||
}
|
||||
|
||||
onExit(cb: (code: number) => void): void {
|
||||
this.exitConsumer = cb
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.subprocess.dispose()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
AgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalAuthorityPolicyConsumerSource } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
|
||||
export type CreateOrAttachOptions = {
|
||||
sessionId: string
|
||||
@@ -33,9 +35,22 @@ export type CreateOrAttachOptions = {
|
||||
claim: AgentSessionExecutionClaim
|
||||
surface: AgentSessionSurfaceBinding
|
||||
}
|
||||
terminalSessionAuthorityVersion?: number
|
||||
terminalSessionAuthorityOperationId?: string
|
||||
worktreeId?: string
|
||||
paneKey?: string
|
||||
paneGeneration?: number
|
||||
terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess
|
||||
/** Set only from the authenticated daemon hello; never copied from an RPC payload. */
|
||||
terminalSessionAuthorityNegotiated?: true
|
||||
/** Exact authenticated daemon connection; never decoded from an RPC payload. */
|
||||
terminalSessionAuthorityPolicyConsumer?: TerminalAuthorityPolicyConsumerSource
|
||||
streamClient: {
|
||||
onAuthorityAccess?: (access: TerminalSessionAuthorityPtyAccess | null) => void
|
||||
onIncarnation?: (incarnationId: PtyIncarnationId) => void
|
||||
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
|
||||
onExit: (code: number, incarnationId: PtyIncarnationId) => void
|
||||
publishAuthorityExit?: (code: number | null, signal: string | null) => Promise<void>
|
||||
}
|
||||
/** Lets the daemon route output under the adopted owner's canonical id before
|
||||
* attaching its stream callbacks. */
|
||||
@@ -53,4 +68,18 @@ export type CreateOrAttachResult = {
|
||||
attachToken: symbol
|
||||
incarnationId: PtyIncarnationId
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess
|
||||
}
|
||||
|
||||
export function createOrAttachRequestsTerminalSessionAuthority(
|
||||
options: CreateOrAttachOptions
|
||||
): boolean {
|
||||
return (
|
||||
options.terminalSessionAuthorityVersion !== undefined ||
|
||||
options.terminalSessionAuthorityOperationId !== undefined ||
|
||||
options.terminalSessionAuthorityAccess !== undefined ||
|
||||
options.worktreeId !== undefined ||
|
||||
options.paneKey !== undefined ||
|
||||
options.paneGeneration !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown'
|
||||
import type { Session } from './session'
|
||||
import type { TerminalHostOptions } from './terminal-host-options'
|
||||
import type { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions'
|
||||
import type { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
|
||||
export async function disposeTerminalHostSessions(args: {
|
||||
sessions: Map<string, Session>
|
||||
authoritySessions: TerminalHostAuthoritySessions
|
||||
killedTombstones: TerminalHostTombstones
|
||||
onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
|
||||
}): Promise<void> {
|
||||
const requestSettlement = args.authoritySessions.requestSettlement()
|
||||
if (requestSettlement) {
|
||||
await requestSettlement
|
||||
}
|
||||
const pendingSessions = new Map(
|
||||
args.authoritySessions
|
||||
.unpublishedSessions()
|
||||
.map((session) => [session.sessionId, session] as const)
|
||||
)
|
||||
await shutdownTerminalHostSessions(args.sessions, args.onFinalCheckpoint)
|
||||
await shutdownTerminalHostSessions(pendingSessions)
|
||||
await args.authoritySessions.finishDispose()
|
||||
args.killedTombstones.clear()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
|
||||
describe('TerminalHost exact PTY operations', () => {
|
||||
it('admits only the live physical incarnation', async () => {
|
||||
let onExit: ((code: number) => void) | undefined
|
||||
const subprocess: SubprocessHandle = {
|
||||
pid: 99999,
|
||||
getForegroundProcess: () => null,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: (callback) => {
|
||||
onExit = callback
|
||||
},
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const host = new TerminalHost({ spawnSubprocess: () => subprocess })
|
||||
|
||||
try {
|
||||
const result = await host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
expect(host.writeExact('session-1', 'stale-incarnation', 'stale')).toBe(false)
|
||||
expect(host.resizeExact('session-1', 'stale-incarnation', 120, 40)).toBe(false)
|
||||
await expect(host.killExact('session-1', 'stale-incarnation')).resolves.toBe(false)
|
||||
expect(host.signalExact('session-1', 'stale-incarnation', 'SIGTERM')).toBe(false)
|
||||
expect(host.clearScrollbackExact('session-1', 'stale-incarnation')).toBe(false)
|
||||
expect(host.writeExact('session-1', result.incarnationId, 'current')).toBe(true)
|
||||
expect(host.resizeExact('session-1', result.incarnationId, 120, 40)).toBe(true)
|
||||
expect(host.signalExact('session-1', result.incarnationId, 'SIGTERM')).toBe(true)
|
||||
expect(host.clearScrollbackExact('session-1', result.incarnationId)).toBe(true)
|
||||
expect(subprocess.write).toHaveBeenCalledWith('current')
|
||||
expect(subprocess.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(subprocess.signal).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(subprocess.clear).toHaveBeenCalledOnce()
|
||||
await expect(host.killExact('session-1', result.incarnationId)).resolves.toBe(true)
|
||||
expect(subprocess.kill).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await host.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type { TakePendingOutputResult, TerminalSnapshot } from './types'
|
||||
import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner'
|
||||
|
||||
export type TerminalHostOptions = {
|
||||
spawnSubprocess: (opts: {
|
||||
@@ -28,4 +29,9 @@ export type TerminalHostOptions = {
|
||||
) => void
|
||||
// Why: tests need deterministic tombstone eviction without thousands of sessions.
|
||||
maxTombstones?: number
|
||||
terminalSessionAuthority?: Readonly<{
|
||||
ptyOwner: TerminalSessionAuthorityPtyOwner
|
||||
authorityHostId?: string
|
||||
}>
|
||||
onTerminalSessionAuthorityFailure?: (error: Error) => void
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { SessionNotFoundError } from './types'
|
||||
import type { Session } from './session'
|
||||
import type { TerminalSessionTeardown } from './terminal-session-teardown'
|
||||
import type { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
import type { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalAuthorityBindingRetiredEffect } from '../session-authority/terminal-session-authority-host-effect-applier'
|
||||
import type { TerminalAuthorityPolicyConsumerConnection } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
|
||||
type KillOptions = { immediate?: boolean }
|
||||
|
||||
export class TerminalHostPtyMutations {
|
||||
constructor(
|
||||
private readonly sessions: ReadonlyMap<string, Session>,
|
||||
private readonly teardown: TerminalSessionTeardown,
|
||||
private readonly tombstones: TerminalHostTombstones,
|
||||
private readonly authority: TerminalHostAuthoritySessions
|
||||
) {}
|
||||
|
||||
alive(sessionId: string): Session {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
throw new SessionNotFoundError(sessionId)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
write(sessionId: string, data: string): void {
|
||||
this.mutable(sessionId).write(data)
|
||||
}
|
||||
|
||||
writeExact(sessionId: string, incarnationId: string, data: string): boolean {
|
||||
const session = this.legacyExact(sessionId, incarnationId)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.write(data)
|
||||
return true
|
||||
}
|
||||
|
||||
writeAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
data: string
|
||||
): boolean {
|
||||
const session = this.authority.exactSession(sessionId, access)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.write(data)
|
||||
return true
|
||||
}
|
||||
|
||||
resize(sessionId: string, cols: number, rows: number): void {
|
||||
this.mutable(sessionId).resize(cols, rows)
|
||||
}
|
||||
|
||||
resizeExact(sessionId: string, incarnationId: string, cols: number, rows: number): boolean {
|
||||
const session = this.legacyExact(sessionId, incarnationId)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.resize(cols, rows)
|
||||
return true
|
||||
}
|
||||
|
||||
resizeAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
cols: number,
|
||||
rows: number
|
||||
): boolean {
|
||||
const session = this.authority.exactSession(sessionId, access)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.resize(cols, rows)
|
||||
return true
|
||||
}
|
||||
|
||||
pauseProducer(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session?.isAlive) {
|
||||
session.pauseProducer()
|
||||
}
|
||||
}
|
||||
|
||||
resumeProducer(sessionId: string): void {
|
||||
this.sessions.get(sessionId)?.resumeProducer()
|
||||
}
|
||||
|
||||
supportsExactHeldProducerPause(sessionId: string, incarnationId: string): boolean {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return Boolean(
|
||||
session?.isAlive &&
|
||||
session.incarnationId === incarnationId &&
|
||||
session.supportsExactHeldProducerPause()
|
||||
)
|
||||
}
|
||||
|
||||
acquireExactHeldProducerPause(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
ownerId: string,
|
||||
token: string
|
||||
): boolean {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return Boolean(
|
||||
session?.isAlive &&
|
||||
session.incarnationId === incarnationId &&
|
||||
session.acquireExactHeldProducerPause(ownerId, token)
|
||||
)
|
||||
}
|
||||
|
||||
releaseExactHeldProducerPause(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
ownerId: string,
|
||||
token: string
|
||||
): boolean {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return Boolean(
|
||||
session?.isAlive &&
|
||||
session.incarnationId === incarnationId &&
|
||||
session.releaseExactHeldProducerPause(ownerId, token)
|
||||
)
|
||||
}
|
||||
|
||||
releaseExactHeldProducerPauses(sessionId: string, incarnationId: string, ownerId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session?.isAlive && session.incarnationId === incarnationId) {
|
||||
session.releaseExactHeldProducerPauses(ownerId)
|
||||
}
|
||||
}
|
||||
|
||||
kill(sessionId: string, options: KillOptions = {}): Promise<void> {
|
||||
if (this.authority.accessFor(sessionId)) {
|
||||
throw new SessionNotFoundError(sessionId)
|
||||
}
|
||||
if (!this.teardown.get(sessionId)) {
|
||||
this.alive(sessionId)
|
||||
}
|
||||
return this.killOwned(sessionId, options)
|
||||
}
|
||||
|
||||
async killExact(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
options: KillOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (!this.legacyExact(sessionId, incarnationId)) {
|
||||
return false
|
||||
}
|
||||
await this.killOwned(sessionId, options)
|
||||
return true
|
||||
}
|
||||
|
||||
async killAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
policyConsumer: TerminalAuthorityPolicyConsumerConnection,
|
||||
options: KillOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (!(await this.authority.closeExact(sessionId, access, policyConsumer))) {
|
||||
return false
|
||||
}
|
||||
await this.killOwned(sessionId, options)
|
||||
return true
|
||||
}
|
||||
|
||||
async ensureAuthorityBindingRetired(
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
reason: TerminalAuthorityBindingRetiredEffect['reason']
|
||||
): Promise<void> {
|
||||
if (reason === 'exit') {
|
||||
return
|
||||
}
|
||||
const session = this.authority.exactRememberedSession(access)
|
||||
if (!session) {
|
||||
throw new Error('terminal_session_authority_physical_shutdown_pending')
|
||||
}
|
||||
await this.killOwned(session.sessionId, { immediate: true })
|
||||
}
|
||||
|
||||
signal(sessionId: string, signal: string): void {
|
||||
this.mutable(sessionId).signal(signal)
|
||||
}
|
||||
|
||||
signalExact(sessionId: string, incarnationId: string, signal: string): boolean {
|
||||
const session = this.legacyExact(sessionId, incarnationId)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.signal(signal)
|
||||
return true
|
||||
}
|
||||
|
||||
signalAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
signal: string
|
||||
): boolean {
|
||||
const session = this.authority.exactSession(sessionId, access)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.signal(signal)
|
||||
return true
|
||||
}
|
||||
|
||||
clearScrollback(sessionId: string): void {
|
||||
this.mutable(sessionId).clearScrollback()
|
||||
}
|
||||
|
||||
clearScrollbackExact(sessionId: string, incarnationId: string): boolean {
|
||||
const session = this.legacyExact(sessionId, incarnationId)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.clearScrollback()
|
||||
return true
|
||||
}
|
||||
|
||||
clearScrollbackAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess
|
||||
): boolean {
|
||||
const session = this.authority.exactSession(sessionId, access)
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
session.clearScrollback()
|
||||
return true
|
||||
}
|
||||
|
||||
private mutable(sessionId: string): Session {
|
||||
const session = this.alive(sessionId)
|
||||
if (this.authority.accessFor(sessionId)) {
|
||||
throw new SessionNotFoundError(sessionId)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private legacyExact(sessionId: string, incarnationId: string): Session | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (
|
||||
!session?.isAlive ||
|
||||
session.incarnationId !== incarnationId ||
|
||||
this.authority.accessFor(sessionId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private killOwned(sessionId: string, options: KillOptions): Promise<void> {
|
||||
const pending = this.teardown.get(sessionId)
|
||||
if (pending) {
|
||||
return Promise.resolve(
|
||||
options.immediate ? this.teardown.requestImmediate(sessionId) : pending
|
||||
)
|
||||
}
|
||||
const killed = this.teardown.killSession(
|
||||
sessionId,
|
||||
this.alive(sessionId),
|
||||
options.immediate === true
|
||||
)
|
||||
this.tombstones.record(sessionId)
|
||||
return Promise.resolve(killed)
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,20 @@ import type { CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
import type { TerminalHostOptions } from './terminal-host-options'
|
||||
import type { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
import type { TerminalSessionTeardown } from './terminal-session-teardown'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import { SessionNotFoundError } from './types'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
|
||||
export type TerminalHostSessionCreationHooks = Readonly<{
|
||||
wrapSubprocess: (
|
||||
subprocess: ReturnType<TerminalHostOptions['spawnSubprocess']>
|
||||
) => ReturnType<TerminalHostOptions['spawnSubprocess']>
|
||||
onSpawnFailure: (error: unknown) => Promise<void>
|
||||
beforePublish: (session: Session) => Promise<TerminalSessionAuthorityPtyAccess>
|
||||
afterPublish: (session: Session) => void
|
||||
onPostDispatchFailure: (error: unknown) => void
|
||||
}>
|
||||
|
||||
type TerminalHostSessionCreateDependencies = {
|
||||
sessions: Map<string, Session>
|
||||
sessionTeardown: TerminalSessionTeardown
|
||||
@@ -20,7 +31,8 @@ type TerminalHostSessionCreateDependencies = {
|
||||
creationFenced: boolean
|
||||
onDeadSessionRemoved: (sessionId: string) => void
|
||||
onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void
|
||||
onSessionExit: (sessionId: string, generation: string | undefined) => void
|
||||
onSessionExit: (sessionId: string, generation: string | undefined, code: number) => void
|
||||
creationHooks?: TerminalHostSessionCreationHooks
|
||||
}
|
||||
|
||||
export async function createOrAttachTerminalSession(
|
||||
@@ -42,6 +54,7 @@ export async function createOrAttachTerminalSession(
|
||||
if (existing && existing.isAlive && !existing.isTerminating) {
|
||||
const snapshot = existing.getSnapshot()
|
||||
existing.detachAllClients()
|
||||
opts.streamClient.onIncarnation?.(existing.incarnationId)
|
||||
const token = existing.attachClient(opts.streamClient)
|
||||
return {
|
||||
isNew: false,
|
||||
@@ -74,70 +87,86 @@ export async function createOrAttachTerminalSession(
|
||||
deps.killedTombstones.clearForCreate(opts.sessionId)
|
||||
const size = normalizePtySize(opts.cols, opts.rows)
|
||||
const wslDistro = resolveWslSessionContext(opts)?.distro
|
||||
const subprocess = deps.spawnSubprocess({
|
||||
sessionId: opts.sessionId,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
envToDelete: opts.envToDelete,
|
||||
command: opts.command,
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
|
||||
})
|
||||
|
||||
// Why: a fallback shell does not emit the preferred shell's ready marker;
|
||||
// retaining the stale capability would indefinitely queue its first command.
|
||||
const shellReadySupported =
|
||||
(opts.shellReadySupported ?? false) &&
|
||||
(subprocess.shellPath === undefined || shellPathSupportsPtyStartupBarrier(subprocess.shellPath))
|
||||
const session = new Session({
|
||||
sessionId: opts.sessionId,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
|
||||
launchAgent: opts.launchAgent,
|
||||
subprocess,
|
||||
ownerBackend: resolvePtyOwnerBackend({
|
||||
platform: process.platform,
|
||||
shellPath: subprocess.shellPath,
|
||||
wslDistro
|
||||
}),
|
||||
shellReadySupported,
|
||||
historySeedChunks: opts.historySeedChunks,
|
||||
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
|
||||
wslDistro,
|
||||
onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration),
|
||||
...(opts.shellReadyTimeoutMs !== undefined
|
||||
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
|
||||
: {})
|
||||
})
|
||||
|
||||
deps.sessions.set(opts.sessionId, session)
|
||||
deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive)
|
||||
const token = session.attachClient(opts.streamClient)
|
||||
|
||||
if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) {
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
// Why: only Orca-wrapped shells advertise the paste-safe startup barrier.
|
||||
session.write(
|
||||
buildStartupCommandSubmission(opts.command, {
|
||||
submit,
|
||||
bracketedPasteSafe: shellReadySupported
|
||||
})
|
||||
)
|
||||
let subprocess: ReturnType<TerminalHostOptions['spawnSubprocess']>
|
||||
try {
|
||||
subprocess = deps.spawnSubprocess({
|
||||
sessionId: opts.sessionId,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
envToDelete: opts.envToDelete,
|
||||
command: opts.command,
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
|
||||
})
|
||||
} catch (error) {
|
||||
await deps.creationHooks?.onSpawnFailure(error)
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
const sessionSubprocess = deps.creationHooks?.wrapSubprocess(subprocess) ?? subprocess
|
||||
// Why: a fallback shell does not emit the preferred shell's ready marker;
|
||||
// retaining the stale capability would indefinitely queue its first command.
|
||||
const shellReadySupported =
|
||||
(opts.shellReadySupported ?? false) &&
|
||||
(subprocess.shellPath === undefined ||
|
||||
shellPathSupportsPtyStartupBarrier(subprocess.shellPath))
|
||||
const session = new Session({
|
||||
sessionId: opts.sessionId,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
|
||||
launchAgent: opts.launchAgent,
|
||||
subprocess: sessionSubprocess,
|
||||
ownerBackend: resolvePtyOwnerBackend({
|
||||
platform: process.platform,
|
||||
shellPath: subprocess.shellPath,
|
||||
wslDistro
|
||||
}),
|
||||
shellReadySupported,
|
||||
historySeedChunks: opts.historySeedChunks,
|
||||
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
|
||||
wslDistro,
|
||||
onExit: (code) => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration, code),
|
||||
...(opts.shellReadyTimeoutMs !== undefined
|
||||
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
|
||||
: {})
|
||||
})
|
||||
const terminalSessionAuthorityAccess = await deps.creationHooks?.beforePublish(session)
|
||||
|
||||
return {
|
||||
isNew: true,
|
||||
snapshot: null,
|
||||
pid: subprocess.pid,
|
||||
shellState: session.shellState,
|
||||
incarnationId: session.incarnationId,
|
||||
...getDaemonSessionResultMetadata(session),
|
||||
attachToken: token
|
||||
deps.sessions.set(opts.sessionId, session)
|
||||
deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive)
|
||||
opts.streamClient.onIncarnation?.(session.incarnationId)
|
||||
const token = session.attachClient(opts.streamClient)
|
||||
deps.creationHooks?.afterPublish(session)
|
||||
|
||||
if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) {
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
// Why: only Orca-wrapped shells advertise the paste-safe startup barrier.
|
||||
session.write(
|
||||
buildStartupCommandSubmission(opts.command, {
|
||||
submit,
|
||||
bracketedPasteSafe: shellReadySupported
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
isNew: true,
|
||||
snapshot: null,
|
||||
pid: subprocess.pid,
|
||||
shellState: session.shellState,
|
||||
incarnationId: session.incarnationId,
|
||||
...getDaemonSessionResultMetadata(session),
|
||||
attachToken: token,
|
||||
...(terminalSessionAuthorityAccess ? { terminalSessionAuthorityAccess } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
deps.creationHooks?.onPostDispatchFailure(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
|
||||
import type { Session } from './session'
|
||||
import type { SessionInfo } from './types'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
export function listLiveTerminalHostSessions(
|
||||
sessions: ReadonlyMap<string, Session>,
|
||||
agentSessionOwners: ClaimedAgentPtyOwnerRegistry
|
||||
agentSessionOwners: ClaimedAgentPtyOwnerRegistry,
|
||||
authorityAccessFor?: (physicalPtyId: string) => TerminalSessionAuthorityPtyAccess | null
|
||||
): SessionInfo[] {
|
||||
const result: SessionInfo[] = []
|
||||
for (const session of sessions.values()) {
|
||||
@@ -12,9 +14,11 @@ export function listLiveTerminalHostSessions(
|
||||
continue
|
||||
}
|
||||
const size = session.getAppliedSize()
|
||||
const authorityAccess = authorityAccessFor?.(session.sessionId) ?? null
|
||||
result.push({
|
||||
sessionId: session.sessionId,
|
||||
incarnationId: session.incarnationId,
|
||||
...(authorityAccess ? { terminalSessionAuthorityAccess: authorityAccess } : {}),
|
||||
state: session.state,
|
||||
shellState: session.shellState,
|
||||
isAlive: true,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Session } from './session'
|
||||
import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
|
||||
import type { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions'
|
||||
import type { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
|
||||
import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
|
||||
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
|
||||
export class TerminalHostSessionQueries {
|
||||
constructor(
|
||||
private readonly sessions: ReadonlyMap<string, Session>,
|
||||
private readonly owners: ClaimedAgentPtyOwnerRegistry,
|
||||
private readonly authority: TerminalHostAuthoritySessions,
|
||||
private readonly tombstones: TerminalHostTombstones,
|
||||
private readonly alive: (sessionId: string) => Session
|
||||
) {}
|
||||
|
||||
detach(sessionId: string, token: symbol): void {
|
||||
this.sessions.get(sessionId)?.detachClient(token)
|
||||
}
|
||||
|
||||
async getCwd(sessionId: string): Promise<string | null> {
|
||||
return await resolveTerminalHostSessionCwd(this.alive(sessionId))
|
||||
}
|
||||
|
||||
getForegroundProcess(sessionId: string): string | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.isAlive ? session.getForegroundProcess() : null
|
||||
}
|
||||
|
||||
inspectProcess(sessionId: string): {
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
} {
|
||||
const foregroundProcess = this.alive(sessionId).getForegroundProcess()
|
||||
return {
|
||||
foregroundProcess,
|
||||
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess)
|
||||
}
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(sessionId: string): Promise<string | null> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.isAlive ? await session.confirmForegroundProcess() : null
|
||||
}
|
||||
|
||||
getSnapshot(sessionId: string, options: { scrollbackRows?: number }): TerminalSnapshot | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.isAlive ? session.getSnapshot(options) : null
|
||||
}
|
||||
|
||||
getPartialEscapeTailAnsi(sessionId: string): string {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.isAlive ? session.getPartialEscapeTailAnsi() : ''
|
||||
}
|
||||
|
||||
getAppliedSize(sessionId: string): { cols: number; rows: number } | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.isAlive ? session.getAppliedSize() : null
|
||||
}
|
||||
|
||||
takePendingOutput(
|
||||
sessionId: string,
|
||||
includeSnapshot: boolean,
|
||||
options: { teardownSnapshot?: boolean }
|
||||
): TakePendingOutputResult | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return session?.isAlive ? session.takePendingOutput(includeSnapshot, options) : null
|
||||
}
|
||||
|
||||
isKilled(sessionId: string): boolean {
|
||||
return this.tombstones.has(sessionId)
|
||||
}
|
||||
|
||||
listSessions(): SessionInfo[] {
|
||||
return listLiveTerminalHostSessions(this.sessions, this.owners, (sessionId) =>
|
||||
this.authority.accessFor(sessionId)
|
||||
)
|
||||
}
|
||||
}
|
||||
+215
-111
@@ -1,22 +1,27 @@
|
||||
import type { Session } from './session'
|
||||
import {
|
||||
SessionNotFoundError,
|
||||
type SessionInfo,
|
||||
type TakePendingOutputResult,
|
||||
type TerminalSnapshot
|
||||
} from './types'
|
||||
import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
|
||||
import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
import type { TerminalHostOptions } from './terminal-host-options'
|
||||
import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown'
|
||||
import { TerminalSessionTeardown } from './terminal-session-teardown'
|
||||
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
|
||||
import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim'
|
||||
import {
|
||||
createOrAttachClaimedAgentSession,
|
||||
type InternalCreateOrAttachOptions
|
||||
} from './terminal-host-agent-session-claim'
|
||||
import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations'
|
||||
import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
|
||||
import { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
|
||||
import { createOrAttachTerminalSession } from './terminal-host-session-create'
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import {
|
||||
createOrAttachTerminalSession,
|
||||
type TerminalHostSessionCreationHooks
|
||||
} from './terminal-host-session-create'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation'
|
||||
import type { TerminalAuthorityBindingRetiredEffect } from '../session-authority/terminal-session-authority-host-effect-applier'
|
||||
import { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions'
|
||||
import { TerminalHostPtyMutations } from './terminal-host-pty-mutations'
|
||||
import { TerminalHostSessionQueries } from './terminal-host-session-queries'
|
||||
import { disposeTerminalHostSessions } from './terminal-host-disposal'
|
||||
import type { TerminalAuthorityPolicyConsumerConnection } from '../session-authority/terminal-session-authority-policy-consumers'
|
||||
|
||||
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
export type { TerminalHostOptions } from './terminal-host-options'
|
||||
@@ -35,6 +40,9 @@ export class TerminalHost {
|
||||
private disposePromise: Promise<void> | null = null
|
||||
private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
|
||||
private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations()
|
||||
private readonly authoritySessions: TerminalHostAuthoritySessions
|
||||
private readonly mutations: TerminalHostPtyMutations
|
||||
private readonly queries: TerminalHostSessionQueries
|
||||
|
||||
constructor(opts: TerminalHostOptions) {
|
||||
this.spawnSubprocess = opts.spawnSubprocess
|
||||
@@ -42,76 +50,182 @@ export class TerminalHost {
|
||||
this.onFinalCheckpoint = opts.onFinalCheckpoint
|
||||
this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES
|
||||
this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)
|
||||
this.authoritySessions = new TerminalHostAuthoritySessions(
|
||||
opts.terminalSessionAuthority,
|
||||
opts.onTerminalSessionAuthorityFailure,
|
||||
{
|
||||
sessions: this.sessions,
|
||||
createPhysical: (options, hooks) => this.createOrAttachPhysicalSession(options, hooks),
|
||||
releaseExited: (sessionId, generation) => this.releaseExitedSession(sessionId, generation),
|
||||
fenceCreation: () => {
|
||||
this.creationFenced = true
|
||||
}
|
||||
}
|
||||
)
|
||||
this.mutations = new TerminalHostPtyMutations(
|
||||
this.sessions,
|
||||
this.sessionTeardown,
|
||||
this.killedTombstones,
|
||||
this.authoritySessions
|
||||
)
|
||||
this.queries = new TerminalHostSessionQueries(
|
||||
this.sessions,
|
||||
this.agentSessionOwners,
|
||||
this.authoritySessions,
|
||||
this.killedTombstones,
|
||||
(sessionId) => this.mutations.alive(sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {
|
||||
return await createOrAttachClaimedAgentSession({
|
||||
options: opts,
|
||||
owners: this.agentSessionOwners,
|
||||
isLive: (owner) =>
|
||||
this.agentSessionGenerations.isCurrent(
|
||||
owner,
|
||||
Boolean(this.sessions.get(owner.ptyId)?.isAlive)
|
||||
),
|
||||
createOrAttach: async (options) => {
|
||||
if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
return await createOrAttachTerminalSession(options, {
|
||||
sessions: this.sessions,
|
||||
sessionTeardown: this.sessionTeardown,
|
||||
killedTombstones: this.killedTombstones,
|
||||
spawnSubprocess: this.spawnSubprocess,
|
||||
creationFenced: this.creationFenced,
|
||||
onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),
|
||||
onSessionCreated: (sessionId, generation, isAlive) =>
|
||||
this.agentSessionGenerations.remember(sessionId, generation, isAlive),
|
||||
onSessionExit: (sessionId, generation) => {
|
||||
this.agentSessionOwners.release(sessionId, generation)
|
||||
this.agentSessionGenerations.forget(sessionId, generation)
|
||||
this.reapSession(sessionId)
|
||||
}
|
||||
})
|
||||
}
|
||||
return await this.authoritySessions.enqueue(opts.sessionId, async () =>
|
||||
createOrAttachClaimedAgentSession({
|
||||
options: opts,
|
||||
owners: this.agentSessionOwners,
|
||||
isLive: (owner) =>
|
||||
this.agentSessionGenerations.isCurrent(
|
||||
owner,
|
||||
Boolean(this.sessions.get(owner.ptyId)?.isAlive)
|
||||
),
|
||||
createOrAttach: (options) => this.authoritySessions.createOrAttach(options)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private async createOrAttachPhysicalSession(
|
||||
options: InternalCreateOrAttachOptions,
|
||||
creationHooks?: TerminalHostSessionCreationHooks
|
||||
): Promise<CreateOrAttachResult> {
|
||||
if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
return await createOrAttachTerminalSession(options, {
|
||||
sessions: this.sessions,
|
||||
sessionTeardown: this.sessionTeardown,
|
||||
killedTombstones: this.killedTombstones,
|
||||
spawnSubprocess: this.spawnSubprocess,
|
||||
creationFenced: this.creationFenced,
|
||||
onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),
|
||||
onSessionCreated: (sessionId, generation, isAlive) =>
|
||||
this.agentSessionGenerations.remember(sessionId, generation, isAlive),
|
||||
onSessionExit: (sessionId, generation, code) =>
|
||||
this.authoritySessions.handleExit(sessionId, generation, code),
|
||||
...(creationHooks ? { creationHooks } : {})
|
||||
})
|
||||
}
|
||||
|
||||
private releaseExitedSession(sessionId: string, generation: string | undefined): void {
|
||||
this.agentSessionOwners.release(sessionId, generation)
|
||||
this.agentSessionGenerations.forget(sessionId, generation)
|
||||
this.reapSession(sessionId)
|
||||
}
|
||||
|
||||
async recordSemanticOutcomeExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
fact: TerminalSessionAuthoritySemanticFact
|
||||
): Promise<boolean> {
|
||||
return await this.authoritySessions.recordSemanticOutcomeExact(sessionId, access, fact)
|
||||
}
|
||||
|
||||
write(sessionId: string, data: string): void {
|
||||
this.getAliveSession(sessionId).write(data)
|
||||
this.mutations.write(sessionId, data)
|
||||
}
|
||||
|
||||
writeExact(sessionId: string, incarnationId: string, data: string): boolean {
|
||||
return this.mutations.writeExact(sessionId, incarnationId, data)
|
||||
}
|
||||
|
||||
writeAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
data: string
|
||||
): boolean {
|
||||
return this.mutations.writeAuthorityExact(sessionId, access, data)
|
||||
}
|
||||
|
||||
closeStartupQueryAuthority(sessionId: string): number {
|
||||
return this.getAliveSession(sessionId).closeStartupQueryAuthority()
|
||||
return this.mutations.alive(sessionId).closeStartupQueryAuthority()
|
||||
}
|
||||
|
||||
resize(sessionId: string, cols: number, rows: number): void {
|
||||
this.getAliveSession(sessionId).resize(cols, rows)
|
||||
this.mutations.resize(sessionId, cols, rows)
|
||||
}
|
||||
|
||||
resizeExact(sessionId: string, incarnationId: string, cols: number, rows: number): boolean {
|
||||
return this.mutations.resizeExact(sessionId, incarnationId, cols, rows)
|
||||
}
|
||||
|
||||
resizeAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
cols: number,
|
||||
rows: number
|
||||
): boolean {
|
||||
return this.mutations.resizeAuthorityExact(sessionId, access, cols, rows)
|
||||
}
|
||||
|
||||
// Why null-not-throw (unlike write/resize): pause/resume are best-effort hints against a session that may have exited.
|
||||
pauseProducer(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return
|
||||
}
|
||||
session.pauseProducer()
|
||||
this.mutations.pauseProducer(sessionId)
|
||||
}
|
||||
|
||||
resumeProducer(sessionId: string): void {
|
||||
this.sessions.get(sessionId)?.resumeProducer()
|
||||
this.mutations.resumeProducer(sessionId)
|
||||
}
|
||||
|
||||
supportsExactHeldProducerPause(sessionId: string, incarnationId: string): boolean {
|
||||
return this.mutations.supportsExactHeldProducerPause(sessionId, incarnationId)
|
||||
}
|
||||
|
||||
acquireExactHeldProducerPause(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
ownerId: string,
|
||||
token: string
|
||||
): boolean {
|
||||
return this.mutations.acquireExactHeldProducerPause(sessionId, incarnationId, ownerId, token)
|
||||
}
|
||||
|
||||
releaseExactHeldProducerPause(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
ownerId: string,
|
||||
token: string
|
||||
): boolean {
|
||||
return this.mutations.releaseExactHeldProducerPause(sessionId, incarnationId, ownerId, token)
|
||||
}
|
||||
|
||||
releaseExactHeldProducerPauses(sessionId: string, incarnationId: string, ownerId: string): void {
|
||||
this.mutations.releaseExactHeldProducerPauses(sessionId, incarnationId, ownerId)
|
||||
}
|
||||
|
||||
kill(sessionId: string, opts: { immediate?: boolean } = {}): Promise<void> {
|
||||
const pending = this.sessionTeardown.get(sessionId)
|
||||
if (pending) {
|
||||
return Promise.resolve(
|
||||
opts.immediate ? this.sessionTeardown.requestImmediate(sessionId) : pending
|
||||
)
|
||||
}
|
||||
const session = this.getAliveSession(sessionId)
|
||||
const killed = this.sessionTeardown.killSession(sessionId, session, opts.immediate === true)
|
||||
this.killedTombstones.record(sessionId)
|
||||
return Promise.resolve(killed)
|
||||
return this.mutations.kill(sessionId, opts)
|
||||
}
|
||||
|
||||
async killExact(
|
||||
sessionId: string,
|
||||
incarnationId: string,
|
||||
opts: { immediate?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
return await this.mutations.killExact(sessionId, incarnationId, opts)
|
||||
}
|
||||
|
||||
async killAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
policyConsumer: TerminalAuthorityPolicyConsumerConnection,
|
||||
opts: { immediate?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
return await this.mutations.killAuthorityExact(sessionId, access, policyConsumer, opts)
|
||||
}
|
||||
|
||||
ensureAuthorityBindingRetired(
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
reason: TerminalAuthorityBindingRetiredEffect['reason']
|
||||
): Promise<void> {
|
||||
return this.mutations.ensureAuthorityBindingRetired(access, reason)
|
||||
}
|
||||
|
||||
// Why: dispose a dead session's emulator so exited terminals don't pin ~5000 rows of scrollback for the daemon's life.
|
||||
@@ -126,75 +240,73 @@ export class TerminalHost {
|
||||
}
|
||||
|
||||
signal(sessionId: string, sig: string): void {
|
||||
this.getAliveSession(sessionId).signal(sig)
|
||||
this.mutations.signal(sessionId, sig)
|
||||
}
|
||||
|
||||
signalExact(sessionId: string, incarnationId: string, sig: string): boolean {
|
||||
return this.mutations.signalExact(sessionId, incarnationId, sig)
|
||||
}
|
||||
|
||||
signalAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess,
|
||||
sig: string
|
||||
): boolean {
|
||||
return this.mutations.signalAuthorityExact(sessionId, access, sig)
|
||||
}
|
||||
|
||||
detach(sessionId: string, token: symbol): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
session?.detachClient(token)
|
||||
this.queries.detach(sessionId, token)
|
||||
}
|
||||
|
||||
async getCwd(sessionId: string): Promise<string | null> {
|
||||
return await resolveTerminalHostSessionCwd(this.getAliveSession(sessionId))
|
||||
return await this.queries.getCwd(sessionId)
|
||||
}
|
||||
|
||||
// Why: null-not-throw — fetched for the tab-bar icon, so a vanished pane should quietly yield "no agent".
|
||||
getForegroundProcess(sessionId: string): string | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.getForegroundProcess()
|
||||
return this.queries.getForegroundProcess(sessionId)
|
||||
}
|
||||
|
||||
inspectProcess(sessionId: string): {
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
} {
|
||||
const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess()
|
||||
return {
|
||||
foregroundProcess,
|
||||
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess)
|
||||
}
|
||||
return this.queries.inspectProcess(sessionId)
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(sessionId: string): Promise<string | null> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.confirmForegroundProcess()
|
||||
return await this.queries.confirmForegroundProcess(sessionId)
|
||||
}
|
||||
|
||||
clearScrollback(sessionId: string): void {
|
||||
this.getAliveSession(sessionId).clearScrollback()
|
||||
this.mutations.clearScrollback(sessionId)
|
||||
}
|
||||
|
||||
clearScrollbackExact(sessionId: string, incarnationId: string): boolean {
|
||||
return this.mutations.clearScrollbackExact(sessionId, incarnationId)
|
||||
}
|
||||
|
||||
clearScrollbackAuthorityExact(
|
||||
sessionId: string,
|
||||
access: TerminalSessionAuthorityPtyAccess
|
||||
): boolean {
|
||||
return this.mutations.clearScrollbackAuthorityExact(sessionId, access)
|
||||
}
|
||||
|
||||
// Why: null-not-throw (unlike getAliveSession) — checkpoint is best-effort against a session that may have just exited.
|
||||
getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.getSnapshot(opts)
|
||||
return this.queries.getSnapshot(sessionId, opts)
|
||||
}
|
||||
|
||||
// Why: scan-authority handoff seed (null-not-throw like getSnapshot) — emulator's dangling incomplete escape at the stream position.
|
||||
getPartialEscapeTailAnsi(sessionId: string): string {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return ''
|
||||
}
|
||||
return session.getPartialEscapeTailAnsi()
|
||||
return this.queries.getPartialEscapeTailAnsi(sessionId)
|
||||
}
|
||||
|
||||
// Why: renderer diffs this against xterm to detect a dropped/coerced daemon-side resize; null-not-throw like getSnapshot.
|
||||
getAppliedSize(sessionId: string): { cols: number; rows: number } | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.getAppliedSize()
|
||||
return this.queries.getAppliedSize(sessionId)
|
||||
}
|
||||
|
||||
// Why: null-not-throw like getSnapshot — incremental checkpoints are best-effort against a just-exited session.
|
||||
@@ -203,19 +315,15 @@ export class TerminalHost {
|
||||
includeSnapshot: boolean,
|
||||
opts: { teardownSnapshot?: boolean } = {}
|
||||
): TakePendingOutputResult | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.takePendingOutput(includeSnapshot, opts)
|
||||
return this.queries.takePendingOutput(sessionId, includeSnapshot, opts)
|
||||
}
|
||||
|
||||
isKilled(sessionId: string): boolean {
|
||||
return this.killedTombstones.has(sessionId)
|
||||
return this.queries.isKilled(sessionId)
|
||||
}
|
||||
|
||||
listSessions(): SessionInfo[] {
|
||||
return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners)
|
||||
return this.queries.listSessions()
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
@@ -235,15 +343,11 @@ export class TerminalHost {
|
||||
}
|
||||
|
||||
private async disposeSessions(): Promise<void> {
|
||||
await shutdownTerminalHostSessions(this.sessions, this.onFinalCheckpoint)
|
||||
this.killedTombstones.clear()
|
||||
}
|
||||
|
||||
private getAliveSession(sessionId: string): Session {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
throw new SessionNotFoundError(sessionId)
|
||||
}
|
||||
return session
|
||||
await disposeTerminalHostSessions({
|
||||
sessions: this.sessions,
|
||||
authoritySessions: this.authoritySessions,
|
||||
killedTombstones: this.killedTombstones,
|
||||
onFinalCheckpoint: this.onFinalCheckpoint
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+26
-34
@@ -20,6 +20,10 @@ import type {
|
||||
AgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol'
|
||||
import type { DaemonPtyOperationRequest } from './daemon-pty-operation-requests'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
import type { DaemonTerminalAuthorityOutcomeRequest } from './daemon-terminal-authority-outcome-requests'
|
||||
import type { DaemonTerminalAuthorityConsumerRequest } from './daemon-terminal-authority-consumer-requests'
|
||||
export type { TerminalModes } from './terminal-modes'
|
||||
import type { TerminalSnapshot } from './terminal-snapshot'
|
||||
export type { TerminalSnapshot } from './terminal-snapshot'
|
||||
@@ -28,13 +32,17 @@ export {
|
||||
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
|
||||
CLEAN_DISCONNECT_PROTOCOL_VERSION,
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
|
||||
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
||||
PROTOCOL_VERSION,
|
||||
PTY_STARTUP_INGRESS_PROTOCOL_VERSION,
|
||||
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
|
||||
TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact,
|
||||
supportsExactHeldProducerPause,
|
||||
supportsTerminalSessionAuthority,
|
||||
supportsPtyStartupIngress
|
||||
} from './daemon-protocol-version'
|
||||
|
||||
@@ -90,6 +98,13 @@ export type CreateOrAttachRequest = {
|
||||
claim: AgentSessionExecutionClaim
|
||||
surface: AgentSessionSurfaceBinding
|
||||
}
|
||||
streamBindingNonce?: string
|
||||
terminalSessionAuthorityVersion?: number
|
||||
terminalSessionAuthorityOperationId?: string
|
||||
worktreeId?: string
|
||||
paneKey?: string
|
||||
paneGeneration?: number
|
||||
terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,34 +120,16 @@ export type CancelCreateOrAttachRequest = {
|
||||
payload: { sessionId: string }
|
||||
}
|
||||
|
||||
export type WriteRequest = {
|
||||
id: string
|
||||
type: 'write'
|
||||
payload: {
|
||||
sessionId: string
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ResizeRequest = {
|
||||
id: string
|
||||
type: 'resize'
|
||||
payload: {
|
||||
sessionId: string
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Producer flow control (v19+) ───────────────────────────────────
|
||||
// Why fire-and-forget notifications (like write/resize): pause/resume ride the
|
||||
// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC
|
||||
// reply, is what guarantees a paused shell can never stay wedged.
|
||||
// Legacy calls stay fire-and-forget with a 5s failsafe. v35 exact calls add
|
||||
// optional incarnation/token fields and require an acknowledged RPC response.
|
||||
export type PausePtyRequest = {
|
||||
id: string
|
||||
type: 'pausePty'
|
||||
payload: {
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
heldPauseToken?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +138,8 @@ export type ResumePtyRequest = {
|
||||
type: 'resumePty'
|
||||
payload: {
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
heldPauseToken?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,15 +155,6 @@ export type SetSessionBackgroundRequest = {
|
||||
}
|
||||
}
|
||||
|
||||
export type KillRequest = {
|
||||
id: string
|
||||
type: 'kill'
|
||||
payload: {
|
||||
sessionId: string
|
||||
immediate?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type SignalRequest = {
|
||||
id: string
|
||||
type: 'signal'
|
||||
@@ -296,12 +286,10 @@ export type DaemonRequest =
|
||||
| CreateOrAttachRequest
|
||||
| HistorySeedProtocol.TerminalHistorySeedTransferRequest
|
||||
| CancelCreateOrAttachRequest
|
||||
| WriteRequest
|
||||
| ResizeRequest
|
||||
| DaemonPtyOperationRequest
|
||||
| PausePtyRequest
|
||||
| ResumePtyRequest
|
||||
| SetSessionBackgroundRequest
|
||||
| KillRequest
|
||||
| SignalRequest
|
||||
| ListSessionsRequest
|
||||
| ShutdownIfIdleRequest
|
||||
@@ -319,6 +307,8 @@ export type DaemonRequest =
|
||||
| GetSizeRequest
|
||||
| TakePendingOutputRequest
|
||||
| CloseStartupQueryAuthorityRequest
|
||||
| DaemonTerminalAuthorityOutcomeRequest
|
||||
| DaemonTerminalAuthorityConsumerRequest
|
||||
|
||||
// ─── RPC Responses (Daemon → Client, on control socket) ────────────
|
||||
|
||||
@@ -358,6 +348,8 @@ export type SystemResolverHealthResult = {
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
mutationRouteToken?: object
|
||||
terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess
|
||||
state: SessionState
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
|
||||
@@ -10,13 +10,22 @@ import { expect, it, vi } from 'vitest'
|
||||
* fsync-only log reads identically for the correct and the broken order. The rename is the boundary
|
||||
* the ordering is defined against, so it has to appear in the same sequence. */
|
||||
const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = []
|
||||
let directoryFsyncFailure: string | null = null
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
fsyncSync: (fd: number) => {
|
||||
syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file')
|
||||
const isDirectory = actual.fstatSync(fd).isDirectory()
|
||||
syscalls.push(isDirectory ? 'fsync:directory' : 'fsync:file')
|
||||
if (isDirectory && directoryFsyncFailure) {
|
||||
const error = new Error(
|
||||
`directory fsync failed: ${directoryFsyncFailure}`
|
||||
) as NodeJS.ErrnoException
|
||||
error.code = directoryFsyncFailure
|
||||
throw error
|
||||
}
|
||||
return actual.fsyncSync(fd)
|
||||
},
|
||||
renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => {
|
||||
@@ -65,3 +74,56 @@ it('fsyncs the file before rename, and the directory after where supported', asy
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes explicit lifecycle secure writes through the durability boundary', async () => {
|
||||
const { writeSecureFileDurable } = await import('../shared/secure-file')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-secure-fsync-'))
|
||||
try {
|
||||
const supported = directoryFsyncSupported(dir)
|
||||
syscalls.length = 0
|
||||
const target = join(dir, 'secret.json')
|
||||
writeSecureFileDurable(target, '{"ok":1}')
|
||||
expect(readFileSync(target, 'utf-8')).toBe('{"ok":1}')
|
||||
expect(syscalls).toEqual(
|
||||
supported ? ['fsync:file', 'rename', 'fsync:directory'] : ['fsync:file', 'rename']
|
||||
)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps ordinary secure writes free of lifecycle fsync overhead', async () => {
|
||||
const { writeSecureFile } = await import('../shared/secure-file')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-secure-write-'))
|
||||
try {
|
||||
syscalls.length = 0
|
||||
writeSecureFile(join(dir, 'ordinary.json'), '{"ok":1}')
|
||||
expect(syscalls).toEqual(['rename'])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates POSIX directory fsync failures but degrades only for unsupported errors', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const { writeFileDurableSync } = await import('./durable-file-write')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-directory-fsync-'))
|
||||
try {
|
||||
const target = join(dir, 'state.json')
|
||||
directoryFsyncFailure = 'EIO'
|
||||
expect(() => writeFileDurableSync(`${target}.tmp`, target, '{"ok":1}')).toThrow(
|
||||
'directory fsync failed'
|
||||
)
|
||||
|
||||
directoryFsyncFailure = 'EINVAL'
|
||||
expect(() =>
|
||||
writeFileDurableSync(`${target}.unsupported.tmp`, target, '{"ok":2}')
|
||||
).not.toThrow()
|
||||
expect(readFileSync(target, 'utf-8')).toBe('{"ok":2}')
|
||||
} finally {
|
||||
directoryFsyncFailure = null
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// empty-file symptom as issue #1158, from a different cause. The .bak ring recovers it at up to an
|
||||
// hour's loss; fsync stops it from happening.
|
||||
|
||||
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { open, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
export { writeFileDurableSync } from '../shared/durable-file-write'
|
||||
|
||||
/**
|
||||
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
|
||||
@@ -24,24 +24,6 @@ async function syncDirectory(directory: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function syncDirectorySync(directory: string): void {
|
||||
let fd: number | null = null
|
||||
try {
|
||||
fd = openSync(directory, 'r')
|
||||
fsyncSync(fd)
|
||||
} catch {
|
||||
// Same platform caveats as syncDirectory.
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try {
|
||||
closeSync(fd)
|
||||
} catch {
|
||||
// Nothing actionable; the fsync already happened or the open failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename and then fsync the containing directory. For callers that already fsynced the temp file
|
||||
* themselves and need the rename made durable.
|
||||
@@ -135,16 +117,3 @@ export async function removeStaleDurableWriteTempFiles(
|
||||
// Directory missing or unreadable — nothing to sweep.
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous counterpart for quit and crash paths that cannot await. */
|
||||
export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void {
|
||||
writeFileSync(tmpPath, payload, 'utf-8')
|
||||
const fd = openSync(tmpPath, 'r+')
|
||||
try {
|
||||
fsyncSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
renameSync(tmpPath, finalPath)
|
||||
syncDirectorySync(dirname(finalPath))
|
||||
}
|
||||
|
||||
+14
-10
@@ -28,6 +28,7 @@ import {
|
||||
registerPaneKeyTeardownListener,
|
||||
getLocalPtyProvider,
|
||||
getSshPtyProvider,
|
||||
publishTerminalSideEffectBatchToRenderer,
|
||||
registerHeadlessPtyRuntime,
|
||||
type CodexHomeLaunchContext
|
||||
} from './ipc/pty'
|
||||
@@ -36,7 +37,8 @@ import {
|
||||
disconnectDaemon,
|
||||
getDaemonProvider,
|
||||
listLiveDaemonPtyIds,
|
||||
shutdownDaemon
|
||||
shutdownDaemon,
|
||||
refreshDaemonAuthorityAppProofIdentity
|
||||
} from './daemon/daemon-init'
|
||||
import {
|
||||
hasRecordedLegacySharedCodexPane,
|
||||
@@ -188,6 +190,7 @@ import {
|
||||
ensureAutoUpdaterConfigured
|
||||
} from './window/attach-main-window-services'
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { registerMainWindowClosedCleanup } from './window/main-window-lifecycle'
|
||||
import {
|
||||
getDashboardPopoutWindow,
|
||||
zoomDashboardPopoutIfFocused
|
||||
@@ -261,7 +264,7 @@ import { AgentAwakeService } from './agent-awake-service'
|
||||
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
||||
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
|
||||
import { quitTeardownStartGate } from './quit-teardown-start-gate'
|
||||
import { beginSshShutdown } from './ipc/ssh'
|
||||
import { beginSshShutdown, disconnectAllSshTargetsForIdentityReset } from './ipc/ssh'
|
||||
import { PluginService } from './plugins/plugin-service'
|
||||
import { PluginKillListService } from './plugins/plugin-kill-list-service'
|
||||
import { getPluginsDataDir } from './plugins/plugin-discovery'
|
||||
@@ -307,7 +310,6 @@ import {
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
|
||||
import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
HEADLESS_RUNTIME_WINDOW_ID,
|
||||
type RuntimeDesktopWindowStatus
|
||||
@@ -1392,7 +1394,7 @@ function openMainWindow(): BrowserWindow {
|
||||
rateLimits.attach(window)
|
||||
// Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later.
|
||||
rateLimits.start({ fetchImmediately: false })
|
||||
window.on('closed', () => {
|
||||
registerMainWindowClosedCleanup(window, () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null
|
||||
}
|
||||
@@ -2367,11 +2369,7 @@ void app.whenReady().then(async () => {
|
||||
agentHookServer.ingestTerminalStatus(event)
|
||||
},
|
||||
// Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer.
|
||||
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:sideEffect', batch)
|
||||
}
|
||||
},
|
||||
onTerminalSideEffects: publishTerminalSideEffectBatchToRenderer,
|
||||
getDesktopWindowStatus: getDesktopWindowStatus,
|
||||
// Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents.
|
||||
getAgentStatusSnapshot: () =>
|
||||
@@ -2819,7 +2817,11 @@ void app.whenReady().then(async () => {
|
||||
preferPinnedWsPort: true
|
||||
}
|
||||
: {}),
|
||||
webClientRoot: getBundledWebClientRoot()
|
||||
webClientRoot: getBundledWebClientRoot(),
|
||||
identityResetHooks: {
|
||||
disconnectSshTargets: disconnectAllSshTargetsForIdentityReset,
|
||||
refreshDaemonProofIdentity: refreshDaemonAuthorityAppProofIdentity
|
||||
}
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc, {
|
||||
getRelayStatus: () => desktopRelayStatus,
|
||||
@@ -2956,6 +2958,8 @@ void app.whenReady().then(async () => {
|
||||
runtimeRpc.setMobileRelayPairingProvider({
|
||||
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
|
||||
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
|
||||
awaitIdentityResetRevocations: (items) => relayService.awaitIdentityResetRevocations(items),
|
||||
identityResetCompleted: () => relayService.identityResetCompleted(),
|
||||
onDemandStateChanged: () => relayService.demandStateChanged(),
|
||||
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
|
||||
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DeviceEntry } from '../runtime/device-registry'
|
||||
import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance'
|
||||
import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import type { E2EEIdentityResetStatus } from '../runtime/e2ee-identity-reset'
|
||||
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
|
||||
import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr'
|
||||
import {
|
||||
@@ -298,6 +299,16 @@ export function registerMobileHandlers(
|
||||
return { revoked: rpcServer.revokeRuntimeAccess(args.deviceId) }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'mobile:getIdentityResetStatus',
|
||||
(): E2EEIdentityResetStatus => rpcServer.getIdentityResetStatus()
|
||||
)
|
||||
|
||||
ipcMain.handle('mobile:resetIdentity', async () => {
|
||||
const result = await rpcServer.resetE2EEIdentity()
|
||||
return { phase: result.phase, transactionId: result.transactionId }
|
||||
})
|
||||
|
||||
ipcMain.handle('mobile:isWebSocketReady', () => {
|
||||
return {
|
||||
ready: rpcServer.getWebSocketEndpoint() !== null,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL,
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_EVENT,
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE,
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
type TerminalAuthorityAppProjectionSnapshot
|
||||
} from '../../shared/terminal-authority-app-projection'
|
||||
import {
|
||||
APP_CONSUMER,
|
||||
boundary,
|
||||
semanticPublication
|
||||
} from '../session-authority/__tests__/terminal-authority-app-projection-fixture'
|
||||
import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store'
|
||||
|
||||
const { handlers, handle, removeHandler } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: { sender: object }, value: unknown) => unknown>(),
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { handle, removeHandler } }))
|
||||
vi.mock('../orca-profiles/profile-index-store', () => ({
|
||||
ensureActiveOrcaProfile: () => ({ profileDirectory: '/tmp/orca-authority-ipc-profile' })
|
||||
}))
|
||||
|
||||
import { registerPtyAuthorityOutcomeIpc } from './pty-authority-outcome-ipc'
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(directories.map((directory) => rm(directory, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('pty authority projection IPC', () => {
|
||||
it('serves committed snapshots, fences senders, and awaits durable bell clear', async () => {
|
||||
handle.mockImplementation(
|
||||
(channel: string, handler: (event: { sender: object }, value: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
)
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-authority-ipc-'))
|
||||
directories.push(directory)
|
||||
const store = await TerminalAuthorityAppProjectionStore.open({
|
||||
directory,
|
||||
databasePath: ':memory:'
|
||||
})
|
||||
store.beginBoundary(boundary(0))
|
||||
store.apply(semanticPublication(1))
|
||||
const events = new EventEmitter()
|
||||
const sent: { channel: string; value: unknown }[] = []
|
||||
const webContents = {
|
||||
on: events.on.bind(events),
|
||||
removeListener: events.removeListener.bind(events),
|
||||
send: (channel: string, value: unknown) => sent.push({ channel, value }),
|
||||
isDestroyed: () => false
|
||||
}
|
||||
registerPtyAuthorityOutcomeIpc({ isDestroyed: () => false, webContents } as never, {
|
||||
processIncarnationId: APP_CONSUMER.consumerIncarnationId,
|
||||
projectionStore: store
|
||||
})
|
||||
const subscribe = handlers.get(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE)!
|
||||
const clearBell = handlers.get(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL)!
|
||||
|
||||
const snapshot = (await subscribe(
|
||||
{ sender: webContents },
|
||||
subscription('renderer-1')
|
||||
)) as TerminalAuthorityAppProjectionSnapshot
|
||||
const row = snapshot.rows[0]!
|
||||
expect(row).toMatchObject({ attention: { pendingBellCount: 1 } })
|
||||
await expect(subscribe({ sender: {} }, subscription('renderer-stale'))).rejects.toThrow(
|
||||
'sender_stale'
|
||||
)
|
||||
|
||||
await expect(
|
||||
clearBell(
|
||||
{ sender: webContents },
|
||||
{
|
||||
version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
consumerId: row.consumerId,
|
||||
namespace: row.namespace,
|
||||
pane: row.pane,
|
||||
expectedEvent: row.facts.bell!.event
|
||||
}
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.attention.pendingBellCount).toBe(0)
|
||||
expect(sent.at(-1)).toMatchObject({
|
||||
channel: TERMINAL_AUTHORITY_APP_PROJECTION_EVENT,
|
||||
value: {
|
||||
rows: [
|
||||
expect.objectContaining({
|
||||
attention: expect.objectContaining({ pendingBellCount: 0 })
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
events.emit('did-start-navigation', {}, 'app://renderer', false, true)
|
||||
await expect(
|
||||
subscribe({ sender: webContents }, subscription('renderer-2', 'renderer-1'))
|
||||
).resolves.toMatchObject({ subscriptionIncarnationId: 'renderer-2' })
|
||||
store.close()
|
||||
})
|
||||
})
|
||||
|
||||
function subscription(
|
||||
subscriptionIncarnationId: string,
|
||||
expectedSubscriptionIncarnationId: string | null = null
|
||||
) {
|
||||
return {
|
||||
version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
subscriptionIncarnationId,
|
||||
expectedSubscriptionIncarnationId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { ipcMain, type BrowserWindow } from 'electron'
|
||||
import {
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL,
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE
|
||||
} from '../../shared/terminal-authority-app-projection'
|
||||
import { parseTerminalAuthorityAppBellClearRequest } from '../../shared/terminal-authority-app-projection-validation'
|
||||
import {
|
||||
assertAuthorityId,
|
||||
type TerminalAuthorityNamespace
|
||||
} from '../../shared/terminal-session-authority-identity'
|
||||
import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement'
|
||||
import type { TerminalAuthorityAppConsumerRetirementRequest } from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import {
|
||||
TerminalAuthorityAppOutcomeHostManager,
|
||||
type TerminalAuthorityAppOutcomeHostRegistration
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-manager'
|
||||
import type {
|
||||
TerminalAuthorityAppNamespaceAdmission,
|
||||
TerminalAuthorityAppOutcomeHostTransport
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-contract'
|
||||
import {
|
||||
TerminalAuthorityAppOutcomeHostTransportSlot,
|
||||
type TerminalAuthorityAppOutcomeHostTransportLease
|
||||
} from '../session-authority/terminal-authority-app-outcome-host-transport-slot'
|
||||
import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store'
|
||||
import { PtyAuthorityProjectionBroker } from './pty-authority-projection-broker'
|
||||
import { PtyAuthorityOutcomeRendererBinding } from './pty-authority-outcome-renderer-binding'
|
||||
|
||||
type AppProjectionRuntime = Readonly<{
|
||||
store: TerminalAuthorityAppProjectionStore
|
||||
manager: TerminalAuthorityAppOutcomeHostManager
|
||||
}>
|
||||
|
||||
type RegisterPtyAuthorityOutcomeIpcOptions = Readonly<{
|
||||
processIncarnationId?: string
|
||||
projectionStore?:
|
||||
| TerminalAuthorityAppProjectionStore
|
||||
| Promise<TerminalAuthorityAppProjectionStore>
|
||||
hostTransport?: TerminalAuthorityAppOutcomeHostTransport
|
||||
}>
|
||||
|
||||
export type PtyAuthorityOutcomeHostRegistration = TerminalAuthorityAppNamespaceAdmission &
|
||||
Readonly<{
|
||||
authenticatedAuthorityHostId: string
|
||||
knownNamespaceIds(): readonly string[]
|
||||
retireNamespace: TerminalAuthorityAppOutcomeHostRegistration['retireNamespace']
|
||||
dispose(): void
|
||||
}>
|
||||
|
||||
export type PtyAuthorityOutcomeResetTarget = Readonly<{
|
||||
authenticatedAuthorityHostId: string
|
||||
namespaceIds: readonly string[]
|
||||
retireNamespace(
|
||||
namespaceId: string,
|
||||
request: TerminalAuthorityAppConsumerRetirementRequest
|
||||
): Promise<TerminalAuthorityConsumerRetirementResult>
|
||||
}>
|
||||
|
||||
let runtimeSnapshot: AppProjectionRuntime | null = null
|
||||
let runtimeReady: Promise<AppProjectionRuntime> | null = null
|
||||
type PendingHostInstallation = {
|
||||
slot: TerminalAuthorityAppOutcomeHostTransportSlot
|
||||
registration: TerminalAuthorityAppOutcomeHostRegistration | null
|
||||
ready: Promise<TerminalAuthorityAppOutcomeHostRegistration>
|
||||
}
|
||||
|
||||
const hostInstallations = new Map<string, PendingHostInstallation>()
|
||||
let authorityAdmissionsFrozen = false
|
||||
const broker = new PtyAuthorityProjectionBroker(() => {
|
||||
if (!runtimeSnapshot) {
|
||||
throw new Error('terminal authority app projection is unavailable')
|
||||
}
|
||||
return runtimeSnapshot.store.snapshotAll()
|
||||
})
|
||||
const rendererBinding = new PtyAuthorityOutcomeRendererBinding(broker)
|
||||
|
||||
export function registerPtyAuthorityOutcomeIpc(
|
||||
mainWindow: BrowserWindow,
|
||||
options: RegisterPtyAuthorityOutcomeIpcOptions = {}
|
||||
): void {
|
||||
void ensureRuntime(options)
|
||||
if (options.hostTransport) {
|
||||
installPtyAuthorityOutcomeHostTransport(options.hostTransport)
|
||||
}
|
||||
rendererBinding.bind(mainWindow)
|
||||
ipcMain.removeHandler(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE)
|
||||
ipcMain.handle(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE, async (event, value: unknown) => {
|
||||
const admission = broker.admitRendererRequest(event.sender)
|
||||
await ensureRuntime()
|
||||
return broker.subscribe(event.sender, value, admission)
|
||||
})
|
||||
ipcMain.removeHandler(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL)
|
||||
ipcMain.handle(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, async (event, value: unknown) => {
|
||||
const admission = broker.admitRendererRequest(event.sender)
|
||||
const runtime = await ensureRuntime()
|
||||
if (admission.rendererToken !== event.sender) {
|
||||
throw new Error('terminal_authority_projection_sender_stale')
|
||||
}
|
||||
const request = parseTerminalAuthorityAppBellClearRequest(value)
|
||||
if (!request) {
|
||||
throw new Error('terminal_authority_projection_bell_clear_invalid')
|
||||
}
|
||||
const changed = runtime.store.clearBell(request)
|
||||
if (changed) {
|
||||
broker.publish([changed], [])
|
||||
}
|
||||
return changed !== null
|
||||
})
|
||||
}
|
||||
|
||||
export function installPtyAuthorityOutcomeHostTransport(
|
||||
transport: TerminalAuthorityAppOutcomeHostTransport
|
||||
): PtyAuthorityOutcomeHostRegistration {
|
||||
const hostId = transport.authenticatedAuthorityHostId
|
||||
assertAuthorityId(hostId, 'authenticatedAuthorityHostId')
|
||||
const existing = hostInstallations.get(hostId)
|
||||
if (existing) {
|
||||
return sourceRegistration(existing, existing.slot.install(transport))
|
||||
}
|
||||
const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(hostId)
|
||||
const source = slot.install(transport)
|
||||
let installation!: PendingHostInstallation
|
||||
const ready = ensureRuntime().then((runtime) => {
|
||||
if (hostInstallations.get(hostId) !== installation) {
|
||||
throw new Error('terminal authority app outcome host installation is stale')
|
||||
}
|
||||
const registration = runtime.manager.installHost(slot)
|
||||
installation.registration = registration
|
||||
return registration
|
||||
})
|
||||
installation = {
|
||||
slot,
|
||||
registration: null,
|
||||
ready
|
||||
}
|
||||
hostInstallations.set(hostId, installation)
|
||||
void installation.ready.catch((error) => {
|
||||
if (hostInstallations.get(hostId) === installation) {
|
||||
console.error('[terminal-authority] app outcome transport unavailable', error)
|
||||
}
|
||||
})
|
||||
return sourceRegistration(installation, source)
|
||||
}
|
||||
|
||||
/** Blocks new app namespace admissions while allowing reset retirement calls through. */
|
||||
export function setPtyAuthorityOutcomeAdmissionFrozen(frozen: boolean): void {
|
||||
authorityAdmissionsFrozen = frozen
|
||||
}
|
||||
|
||||
export function isPtyAuthorityOutcomeAdmissionFrozen(): boolean {
|
||||
return authorityAdmissionsFrozen
|
||||
}
|
||||
|
||||
/** Returns the app authority manager's process identity for exact reset proofs. */
|
||||
export function getPtyAuthorityOutcomeProcessIncarnationId(): string | null {
|
||||
return runtimeSnapshot?.manager.getProcessIncarnationId() ?? null
|
||||
}
|
||||
|
||||
/** Returns the authenticated in-memory host/session inventory captured by the reset record. */
|
||||
export function listPtyAuthorityOutcomeResetTargets(): readonly PtyAuthorityOutcomeResetTarget[] {
|
||||
return Object.freeze(
|
||||
[...hostInstallations.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([hostId, installation]) => {
|
||||
const knownNamespaceIds = installation.registration?.knownNamespaceIds() ?? []
|
||||
return Object.freeze({
|
||||
authenticatedAuthorityHostId: hostId,
|
||||
namespaceIds: Object.freeze([...knownNamespaceIds].sort()),
|
||||
retireNamespace: async (
|
||||
namespaceId: string,
|
||||
request: TerminalAuthorityAppConsumerRetirementRequest
|
||||
) => {
|
||||
const registration = await installation.ready
|
||||
if (!registration.knownNamespaceIds().includes(namespaceId)) {
|
||||
throw new Error(
|
||||
`terminal authority app outcome namespace is unresolved: ${namespaceId}`
|
||||
)
|
||||
}
|
||||
return await registration.retireNamespace(request)
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Drops all old authenticated host/session consumers after retirement acknowledgements commit. */
|
||||
export function disposePtyAuthorityOutcomeResetTransports(): void {
|
||||
for (const installation of hostInstallations.values()) {
|
||||
installation.registration?.dispose()
|
||||
installation.slot.dispose()
|
||||
}
|
||||
hostInstallations.clear()
|
||||
}
|
||||
|
||||
function sourceRegistration(
|
||||
installation: PendingHostInstallation,
|
||||
source: TerminalAuthorityAppOutcomeHostTransportLease
|
||||
): PtyAuthorityOutcomeHostRegistration {
|
||||
const hostId = installation.slot.authenticatedAuthorityHostId
|
||||
const withSourceAdmission: TerminalAuthorityAppNamespaceAdmission['withSourceAdmission'] = async (
|
||||
locator,
|
||||
operation
|
||||
) => {
|
||||
assertAuthorityOutcomeAdmissionOpen()
|
||||
return await source.withCurrent(async (sourceBinding) => {
|
||||
if (hostInstallations.get(hostId) !== installation || !source.isActive()) {
|
||||
throw new Error('terminal authority app outcome host installation is stale')
|
||||
}
|
||||
const registration = await installation.ready
|
||||
const resolved =
|
||||
'namespace' in locator
|
||||
? {
|
||||
namespace: locator.namespace,
|
||||
binding: await registration.bindNamespace(locator.namespace)
|
||||
}
|
||||
: await registration.resolveAndBindNamespace(locator.worktreeId)
|
||||
sourceBinding.bindConnectionGeneration()
|
||||
const binding = Object.freeze({
|
||||
namespace: Object.freeze({ ...resolved.namespace }),
|
||||
assertCurrent: () => {
|
||||
sourceBinding.assertCurrent()
|
||||
resolved.binding.assertCurrent()
|
||||
}
|
||||
})
|
||||
binding.assertCurrent()
|
||||
const result = await operation(binding)
|
||||
binding.assertCurrent()
|
||||
return result
|
||||
})
|
||||
}
|
||||
return Object.freeze({
|
||||
authenticatedAuthorityHostId: hostId,
|
||||
knownNamespaceIds: () => installation.registration?.knownNamespaceIds() ?? [],
|
||||
admitNamespace: (namespace: TerminalAuthorityNamespace) => {
|
||||
assertAuthorityOutcomeAdmissionOpen()
|
||||
return withSourceAdmission({ namespace }, async () => undefined)
|
||||
},
|
||||
resolveAndAdmitNamespace: (worktreeId: string) => {
|
||||
assertAuthorityOutcomeAdmissionOpen()
|
||||
return withSourceAdmission({ worktreeId }, async ({ namespace }) => namespace)
|
||||
},
|
||||
withSourceAdmission,
|
||||
retireNamespace: (request) =>
|
||||
source.withCurrent(async (binding) => {
|
||||
const registration = await installation.ready
|
||||
binding.bindConnectionGeneration()
|
||||
return await registration.retireNamespace(request)
|
||||
}),
|
||||
dispose: () => {
|
||||
source.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assertAuthorityOutcomeAdmissionOpen(): void {
|
||||
if (authorityAdmissionsFrozen) {
|
||||
throw new Error('terminal authority app outcome admission is frozen for identity reset')
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRuntime(
|
||||
options: RegisterPtyAuthorityOutcomeIpcOptions = {}
|
||||
): Promise<AppProjectionRuntime> {
|
||||
if (!runtimeReady) {
|
||||
const profileDirectory = ensureActiveOrcaProfile().profileDirectory
|
||||
const processIncarnationId = options.processIncarnationId ?? `app-process:${randomUUID()}`
|
||||
assertAppProcessIncarnationId(processIncarnationId)
|
||||
runtimeReady = Promise.resolve(
|
||||
options.projectionStore ??
|
||||
TerminalAuthorityAppProjectionStore.open({ directory: profileDirectory })
|
||||
).then((store) => {
|
||||
const manager = new TerminalAuthorityAppOutcomeHostManager(processIncarnationId, {
|
||||
store,
|
||||
onProjection: (change) => broker.publish(change.rows, change.deleted),
|
||||
onError: (error) => console.error('[terminal-authority] app outcome pump failed', error)
|
||||
})
|
||||
runtimeSnapshot = Object.freeze({ store, manager })
|
||||
return runtimeSnapshot
|
||||
})
|
||||
void runtimeReady.catch(() => undefined)
|
||||
}
|
||||
return runtimeReady
|
||||
}
|
||||
|
||||
function assertAppProcessIncarnationId(value: string): void {
|
||||
assertAuthorityId(value, 'app process incarnation')
|
||||
if (!value.startsWith('app-process:')) {
|
||||
throw new Error('terminal authority app process incarnation is invalid')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { BrowserWindow, WebContents } from 'electron'
|
||||
import {
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_EVENT,
|
||||
type TerminalAuthorityAppProjectionDelta
|
||||
} from '../../shared/terminal-authority-app-projection'
|
||||
import type { PtyAuthorityProjectionBroker } from './pty-authority-projection-broker'
|
||||
|
||||
type NavigationHandler = (
|
||||
event: Electron.Event,
|
||||
url: string,
|
||||
isSameDocument: boolean,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
|
||||
type BoundRenderer = Readonly<{
|
||||
webContents: WebContents
|
||||
didStartNavigation: NavigationHandler
|
||||
renderProcessGone: () => void
|
||||
destroyed: () => void
|
||||
}>
|
||||
|
||||
/** Owns renderer event listeners so projection IPC can survive navigation and process loss. */
|
||||
export class PtyAuthorityOutcomeRendererBinding {
|
||||
private boundRenderer: BoundRenderer | null = null
|
||||
|
||||
constructor(private readonly broker: PtyAuthorityProjectionBroker) {}
|
||||
|
||||
bind(mainWindow: BrowserWindow): void {
|
||||
this.clear()
|
||||
const webContents = mainWindow.webContents
|
||||
this.broker.attachRenderer(webContents, (delta: TerminalAuthorityAppProjectionDelta) => {
|
||||
if (
|
||||
mainWindow.isDestroyed() ||
|
||||
(typeof webContents.isDestroyed === 'function' && webContents.isDestroyed())
|
||||
) {
|
||||
throw new Error('terminal_authority_renderer_unavailable')
|
||||
}
|
||||
webContents.send(TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, delta)
|
||||
})
|
||||
const didStartNavigation: NavigationHandler = (_event, _url, isSameDocument, isMainFrame) => {
|
||||
if (isMainFrame && !isSameDocument) {
|
||||
this.broker.resetRenderer(webContents)
|
||||
}
|
||||
}
|
||||
const renderProcessGone = (): void => this.broker.resetRenderer(webContents)
|
||||
const destroyed = (): void => this.broker.detachRenderer(webContents)
|
||||
webContents.on('did-start-navigation', didStartNavigation)
|
||||
webContents.on('render-process-gone', renderProcessGone)
|
||||
webContents.on('destroyed', destroyed)
|
||||
this.boundRenderer = Object.freeze({
|
||||
webContents,
|
||||
didStartNavigation,
|
||||
renderProcessGone,
|
||||
destroyed
|
||||
})
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (!this.boundRenderer) {
|
||||
return
|
||||
}
|
||||
this.broker.detachRenderer(this.boundRenderer.webContents)
|
||||
this.boundRenderer.webContents.removeListener(
|
||||
'did-start-navigation',
|
||||
this.boundRenderer.didStartNavigation
|
||||
)
|
||||
this.boundRenderer.webContents.removeListener(
|
||||
'render-process-gone',
|
||||
this.boundRenderer.renderProcessGone
|
||||
)
|
||||
this.boundRenderer.webContents.removeListener('destroyed', this.boundRenderer.destroyed)
|
||||
this.boundRenderer = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TERMINAL_AUTHORITY_APP_PROJECTION_VERSION } from '../../shared/terminal-authority-app-projection'
|
||||
import { PtyAuthorityProjectionBroker } from './pty-authority-projection-broker'
|
||||
|
||||
describe('PtyAuthorityProjectionBroker', () => {
|
||||
it('returns a snapshot and publishes only to the admitted renderer incarnation', async () => {
|
||||
const row = { consumerId: 'app-profile:test' } as never
|
||||
const send = vi.fn()
|
||||
const renderer = {}
|
||||
const broker = new PtyAuthorityProjectionBroker(() => [row])
|
||||
broker.attachRenderer(renderer, send)
|
||||
|
||||
await expect(broker.subscribe(renderer, subscription('renderer-1'))).resolves.toMatchObject({
|
||||
subscriptionIncarnationId: 'renderer-1',
|
||||
rows: [row]
|
||||
})
|
||||
broker.publish(
|
||||
[row],
|
||||
[
|
||||
{
|
||||
consumerId: 'app-profile:test',
|
||||
namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' },
|
||||
pane: { paneKey: 'pane-1', paneGenerationId: 'generation-1' }
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscriptionIncarnationId: 'renderer-1',
|
||||
rows: [row],
|
||||
deleted: [expect.objectContaining({ consumerId: 'app-profile:test' })]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fences an admitted subscription when navigation starts before it executes', async () => {
|
||||
const renderer = {}
|
||||
const broker = new PtyAuthorityProjectionBroker(() => [])
|
||||
broker.attachRenderer(renderer, vi.fn())
|
||||
const admission = broker.admitRendererRequest(renderer)
|
||||
const subscriptionPromise = broker.subscribe(renderer, subscription('renderer-1'), admission)
|
||||
|
||||
broker.resetRenderer(renderer)
|
||||
|
||||
await expect(subscriptionPromise).rejects.toThrow('subscription_stale')
|
||||
})
|
||||
|
||||
it('accepts a fresh reload after fencing the previous renderer subscription', async () => {
|
||||
const renderer = {}
|
||||
const broker = new PtyAuthorityProjectionBroker(() => [])
|
||||
broker.attachRenderer(renderer, vi.fn())
|
||||
await broker.subscribe(renderer, subscription('renderer-1'))
|
||||
broker.resetRenderer(renderer)
|
||||
|
||||
await expect(
|
||||
broker.subscribe(renderer, subscription('renderer-2', 'renderer-1'))
|
||||
).resolves.toMatchObject({ subscriptionIncarnationId: 'renderer-2' })
|
||||
})
|
||||
})
|
||||
|
||||
function subscription(
|
||||
subscriptionIncarnationId: string,
|
||||
expectedSubscriptionIncarnationId: string | null = null
|
||||
) {
|
||||
return {
|
||||
version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
subscriptionIncarnationId,
|
||||
expectedSubscriptionIncarnationId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
type TerminalAuthorityAppPaneProjection,
|
||||
type TerminalAuthorityAppProjectionDelta,
|
||||
type TerminalAuthorityAppProjectionRowIdentity,
|
||||
type TerminalAuthorityAppProjectionSnapshot
|
||||
} from '../../shared/terminal-authority-app-projection'
|
||||
import { parseTerminalAuthorityAppProjectionSubscribe } from '../../shared/terminal-authority-app-projection-validation'
|
||||
|
||||
type AttachedRenderer = Readonly<{
|
||||
token: object
|
||||
send: (delta: TerminalAuthorityAppProjectionDelta) => void
|
||||
}>
|
||||
|
||||
export type PtyAuthorityProjectionRendererAdmission = Readonly<{
|
||||
rendererToken: object | null
|
||||
lifecycleGeneration: number
|
||||
}>
|
||||
|
||||
export class PtyAuthorityProjectionBroker {
|
||||
private renderer: AttachedRenderer | null = null
|
||||
private rendererLifecycleGeneration = 0
|
||||
private subscriptionIncarnationId: string | null = null
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly snapshotRows: () => readonly TerminalAuthorityAppPaneProjection[]) {}
|
||||
|
||||
attachRenderer(token: object, send: (delta: TerminalAuthorityAppProjectionDelta) => void): void {
|
||||
this.renderer = Object.freeze({ token, send })
|
||||
this.fenceRendererSubscription()
|
||||
}
|
||||
|
||||
resetRenderer(token: object): void {
|
||||
if (this.renderer?.token === token) {
|
||||
this.fenceRendererSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
detachRenderer(token: object): void {
|
||||
if (this.renderer?.token === token) {
|
||||
this.renderer = null
|
||||
this.fenceRendererSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
admitRendererRequest(token: object): PtyAuthorityProjectionRendererAdmission {
|
||||
return Object.freeze({
|
||||
rendererToken: this.renderer?.token === token ? token : null,
|
||||
lifecycleGeneration: this.rendererLifecycleGeneration
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(
|
||||
token: object,
|
||||
value: unknown,
|
||||
admission = this.admitRendererRequest(token)
|
||||
): Promise<TerminalAuthorityAppProjectionSnapshot> {
|
||||
const request = parseTerminalAuthorityAppProjectionSubscribe(value)
|
||||
return this.enqueue(() => {
|
||||
if (this.renderer?.token !== token || admission.rendererToken !== token) {
|
||||
throw new Error('terminal_authority_projection_sender_stale')
|
||||
}
|
||||
if (admission.lifecycleGeneration !== this.rendererLifecycleGeneration) {
|
||||
throw new Error('terminal_authority_projection_subscription_stale')
|
||||
}
|
||||
if (!request) {
|
||||
throw new Error('terminal_authority_projection_subscription_invalid')
|
||||
}
|
||||
const current = this.subscriptionIncarnationId
|
||||
if (
|
||||
current !== null &&
|
||||
current !== request.subscriptionIncarnationId &&
|
||||
current !== request.expectedSubscriptionIncarnationId
|
||||
) {
|
||||
throw new Error('terminal_authority_projection_subscription_stale')
|
||||
}
|
||||
this.subscriptionIncarnationId = request.subscriptionIncarnationId
|
||||
return Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
subscriptionIncarnationId: request.subscriptionIncarnationId,
|
||||
rows: Object.freeze([...this.snapshotRows()])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
publish(
|
||||
rows: readonly TerminalAuthorityAppPaneProjection[],
|
||||
deleted: readonly TerminalAuthorityAppProjectionRowIdentity[] = []
|
||||
): void {
|
||||
const renderer = this.renderer
|
||||
const subscriptionIncarnationId = this.subscriptionIncarnationId
|
||||
if (!renderer || !subscriptionIncarnationId || (rows.length === 0 && deleted.length === 0)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
renderer.send(
|
||||
Object.freeze({
|
||||
version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION,
|
||||
subscriptionIncarnationId,
|
||||
rows: Object.freeze([...rows]),
|
||||
...(deleted.length > 0 ? { deleted: Object.freeze([...deleted]) } : {})
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
this.fenceRendererSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
private fenceRendererSubscription(): void {
|
||||
this.rendererLifecycleGeneration += 1
|
||||
this.subscriptionIncarnationId = null
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T): Promise<T> {
|
||||
const result = this.operationQueue.then(operation)
|
||||
this.operationQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DaemonSessionInfo } from '../daemon/types'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -86,6 +87,7 @@ function makeSession(
|
||||
): DaemonSessionInfo {
|
||||
return {
|
||||
sessionId,
|
||||
incarnationId: `${sessionId}-incarnation`,
|
||||
state: 'running',
|
||||
shellState: 'ready',
|
||||
isAlive: true,
|
||||
@@ -103,22 +105,52 @@ type MockAdapter = {
|
||||
protocolVersion: number
|
||||
listSessions: ReturnType<typeof vi.fn>
|
||||
shutdown: ReturnType<typeof vi.fn>
|
||||
killExact: ReturnType<typeof vi.fn>
|
||||
killAuthorityExact: ReturnType<typeof vi.fn>
|
||||
getPtyMutationRouteToken: ReturnType<typeof vi.fn>
|
||||
mutationRouteToken: object | null
|
||||
}
|
||||
|
||||
function daemonSessionRows(sessions: DaemonSessionInfo[], mutationRouteToken: object | null) {
|
||||
return sessions.map(({ protocolVersion: _pv, ...rest }) => ({
|
||||
...rest,
|
||||
...(mutationRouteToken ? { mutationRouteToken } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
function makeAdapter(
|
||||
protocolVersion: number,
|
||||
sessions: DaemonSessionInfo[],
|
||||
shutdownImpl?: (id: string, immediate: boolean) => Promise<void>
|
||||
shutdownImpl?: (id: string, opts: { immediate?: boolean }) => Promise<void>
|
||||
): MockAdapter {
|
||||
// Why: collectSessions calls adapter.listSessions() (the daemon-side RPC)
|
||||
// and then annotates with adapter.protocolVersion. The mock returns the
|
||||
// *internal* SessionInfo shape (no protocolVersion) since the adapter adds
|
||||
// it. Stripping it here mirrors production behavior.
|
||||
return {
|
||||
const mutationRouteToken = Object.freeze({})
|
||||
const shutdown = vi.fn(shutdownImpl ?? (async () => {}))
|
||||
const adapter: MockAdapter = {
|
||||
protocolVersion,
|
||||
listSessions: vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest)),
|
||||
shutdown: vi.fn(shutdownImpl ?? (async () => {}))
|
||||
listSessions: vi.fn(async () => daemonSessionRows(sessions, adapter.mutationRouteToken)),
|
||||
shutdown,
|
||||
killExact: vi.fn(),
|
||||
killAuthorityExact: vi.fn(),
|
||||
getPtyMutationRouteToken: vi.fn(() => adapter.mutationRouteToken),
|
||||
mutationRouteToken
|
||||
}
|
||||
adapter.killExact.mockImplementation(
|
||||
async (id: string, _incarnationId: string, opts: { immediate?: boolean }) => {
|
||||
await shutdown(id, opts)
|
||||
return true
|
||||
}
|
||||
)
|
||||
adapter.killAuthorityExact.mockImplementation(
|
||||
async (id: string, _access: unknown, opts: { immediate?: boolean }) => {
|
||||
await shutdown(id, opts)
|
||||
return true
|
||||
}
|
||||
)
|
||||
return adapter
|
||||
}
|
||||
|
||||
async function importFresh() {
|
||||
@@ -286,15 +318,15 @@ describe('pty:management IPC handlers', () => {
|
||||
}
|
||||
}
|
||||
current.listSessions = vi.fn(async () =>
|
||||
currentSessions.map(({ protocolVersion: _pv, ...rest }) => rest)
|
||||
daemonSessionRows(currentSessions, current.mutationRouteToken)
|
||||
)
|
||||
legacy.listSessions = vi.fn(async () =>
|
||||
legacySessions.map(({ protocolVersion: _pv, ...rest }) => rest)
|
||||
daemonSessionRows(legacySessions, legacy.mutationRouteToken)
|
||||
)
|
||||
current.shutdown = vi.fn(async (id: string) => {
|
||||
current.shutdown.mockImplementation(async (id: string) => {
|
||||
removeFrom(currentSessions, id)
|
||||
})
|
||||
legacy.shutdown = vi.fn(async (id: string) => {
|
||||
legacy.shutdown.mockImplementation(async (id: string) => {
|
||||
removeFrom(legacySessions, id)
|
||||
})
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
@@ -321,13 +353,13 @@ describe('pty:management IPC handlers', () => {
|
||||
const sessions = [makeSession('stuck')]
|
||||
const current = makeAdapter(5, [])
|
||||
current.listSessions = vi.fn(async () =>
|
||||
sessions.map(({ protocolVersion: _pv, ...rest }) => rest)
|
||||
daemonSessionRows(sessions, current.mutationRouteToken)
|
||||
)
|
||||
// Why: shutdown resolves but the session never leaves listSessions —
|
||||
// simulates a process wedged in uninterruptible sleep. After the poll
|
||||
// window (≈6.5s, past the daemon's 5s SIGTERM→SIGKILL ladder) the
|
||||
// handler must return remainingCount=1 rather than loop forever.
|
||||
current.shutdown = vi.fn(async () => {})
|
||||
current.shutdown.mockImplementation(async () => {})
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
@@ -354,14 +386,14 @@ describe('pty:management IPC handlers', () => {
|
||||
pollCalls += 1
|
||||
if (pollCalls === 1) {
|
||||
// Initial snapshot: a and b are alive.
|
||||
return liveSessions.map(({ protocolVersion: _pv, ...rest }) => rest)
|
||||
return daemonSessionRows(liveSessions, current.mutationRouteToken)
|
||||
}
|
||||
// First poll onward: a and b have been reaped, but the renderer
|
||||
// respawned a fresh pane with id 'c'. 'c' was never in the initial
|
||||
// snapshot, so it must not count as remaining.
|
||||
return [makeSession('c')].map(({ protocolVersion: _pv, ...rest }) => rest)
|
||||
return daemonSessionRows([makeSession('c')], current.mutationRouteToken)
|
||||
})
|
||||
current.shutdown = vi.fn(async () => {})
|
||||
current.shutdown.mockImplementation(async () => {})
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
@@ -376,11 +408,50 @@ describe('pty:management IPC handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not chase a same-ID successor with a new incarnation', async () => {
|
||||
const sessions = [
|
||||
makeSession('reused', { protocolVersion: 33, incarnationId: 'incarnation-old' })
|
||||
]
|
||||
const current = makeAdapter(33, [])
|
||||
current.listSessions = vi.fn(async () =>
|
||||
daemonSessionRows(sessions, current.mutationRouteToken)
|
||||
)
|
||||
current.killExact = vi.fn(async (_id: string, incarnationId: string) => {
|
||||
if (incarnationId !== 'incarnation-old') {
|
||||
return false
|
||||
}
|
||||
sessions.splice(
|
||||
0,
|
||||
1,
|
||||
makeSession('reused', {
|
||||
protocolVersion: 33,
|
||||
incarnationId: 'incarnation-new'
|
||||
})
|
||||
)
|
||||
return true
|
||||
})
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = await runKillAllWithPolls(buildHandlerMap()['pty:management:killAll'])
|
||||
|
||||
expect(result).toEqual({
|
||||
killedCount: 1,
|
||||
remainingCount: 0,
|
||||
killedSessionIds: ['reused']
|
||||
})
|
||||
expect(current.killExact).toHaveBeenCalledOnce()
|
||||
expect(current.killExact).toHaveBeenCalledWith('reused', 'incarnation-old', {
|
||||
immediate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows per-session shutdown rejections without stopping the batch', async () => {
|
||||
const sessionsList = [makeSession('a'), makeSession('b')]
|
||||
const current = makeAdapter(5, [])
|
||||
current.listSessions = vi.fn(async () =>
|
||||
sessionsList.map(({ protocolVersion: _pv, ...rest }) => rest)
|
||||
daemonSessionRows(sessionsList, current.mutationRouteToken)
|
||||
)
|
||||
// Why: a rejecting shutdown for 'a' must not block the shutdown of 'b'.
|
||||
// Since shutdowns fire in parallel (Promise.allSettled), both must be
|
||||
@@ -391,7 +462,7 @@ describe('pty:management IPC handlers', () => {
|
||||
sessionsList.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
current.shutdown = vi.fn(async (id: string) => {
|
||||
current.shutdown.mockImplementation(async (id: string) => {
|
||||
if (id === 'a') {
|
||||
throw new Error('a is stuck')
|
||||
}
|
||||
@@ -413,6 +484,45 @@ describe('pty:management IPC handlers', () => {
|
||||
killedSessionIds: ['b']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mutate or report success when an adapter inventory rejects', async () => {
|
||||
const current = makeAdapter(5, [makeSession('visible')])
|
||||
const legacy = makeAdapter(3, [makeSession('unknown', { protocolVersion: 3 })])
|
||||
legacy.listSessions.mockRejectedValue(new Error('legacy daemon unavailable'))
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy]))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
await expect(buildHandlerMap()['pty:management:killAll']({})).rejects.toThrow(
|
||||
'pty_management_inventory_unavailable:3'
|
||||
)
|
||||
expect(current.killExact).not.toHaveBeenCalled()
|
||||
expect(legacy.killExact).not.toHaveBeenCalled()
|
||||
expect(current.shutdown).not.toHaveBeenCalled()
|
||||
expect(legacy.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not turn a poll-time adapter failure into killed sessions', async () => {
|
||||
const currentSessions = [makeSession('pending')]
|
||||
const current = makeAdapter(5, [])
|
||||
current.listSessions
|
||||
.mockImplementationOnce(async () =>
|
||||
daemonSessionRows(currentSessions, current.mutationRouteToken)
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('daemon disconnected before exit outcome'))
|
||||
current.killExact.mockResolvedValue(true)
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = buildHandlerMap()['pty:management:killAll']({}) as Promise<unknown>
|
||||
const rejected = expect(result).rejects.toThrow('pty_management_inventory_unavailable:5')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
expect(current.killExact).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('killOne', () => {
|
||||
@@ -433,6 +543,134 @@ describe('pty:management IPC handlers', () => {
|
||||
expect(current.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a same-ID successor that replaces the listed incarnation before kill', async () => {
|
||||
const current = makeAdapter(33, [])
|
||||
let currentIncarnation = 'incarnation-old'
|
||||
current.listSessions = vi.fn(async () => {
|
||||
const listedIncarnation = currentIncarnation
|
||||
currentIncarnation = 'incarnation-new'
|
||||
return daemonSessionRows(
|
||||
[
|
||||
makeSession('reused', {
|
||||
protocolVersion: 33,
|
||||
incarnationId: listedIncarnation
|
||||
})
|
||||
],
|
||||
current.mutationRouteToken
|
||||
)
|
||||
})
|
||||
current.killExact = vi.fn(
|
||||
async (_id: string, incarnationId: string) => incarnationId === currentIncarnation
|
||||
)
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = (await buildHandlerMap()['pty:management:killOne'](
|
||||
{},
|
||||
{ sessionId: 'reused' }
|
||||
)) as { success: boolean }
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(current.killExact).toHaveBeenCalledWith('reused', 'incarnation-old', {
|
||||
immediate: true
|
||||
})
|
||||
expect(current.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a session after its adapter route is replaced', async () => {
|
||||
const current = makeAdapter(33, [])
|
||||
current.listSessions = vi.fn(async () => {
|
||||
const listedRouteToken = current.mutationRouteToken
|
||||
current.mutationRouteToken = Object.freeze({})
|
||||
return daemonSessionRows(
|
||||
[makeSession('reused', { protocolVersion: 33, incarnationId: 'incarnation-old' })],
|
||||
listedRouteToken
|
||||
)
|
||||
})
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = (await buildHandlerMap()['pty:management:killOne'](
|
||||
{},
|
||||
{ sessionId: 'reused' }
|
||||
)) as { success: boolean }
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(current.killExact).not.toHaveBeenCalled()
|
||||
expect(current.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects killOne when another adapter inventory is unknown', async () => {
|
||||
const current = makeAdapter(5, [makeSession('visible')])
|
||||
const legacy = makeAdapter(3, [])
|
||||
legacy.listSessions.mockRejectedValue(new Error('legacy daemon unavailable'))
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy]))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = (await buildHandlerMap()['pty:management:killOne'](
|
||||
{},
|
||||
{ sessionId: 'visible' }
|
||||
)) as { success: boolean }
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(current.killExact).not.toHaveBeenCalled()
|
||||
expect(current.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses full authority access and never falls back to incarnation-only kill', async () => {
|
||||
const authorityAccess: TerminalSessionAuthorityPtyAccess = {
|
||||
namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' },
|
||||
pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' },
|
||||
binding: {
|
||||
ownerIncarnationId: 'owner-1',
|
||||
physicalPtyId: 'authority-1',
|
||||
ptyIncarnationId: 'authority-incarnation'
|
||||
}
|
||||
}
|
||||
const session = {
|
||||
...makeSession('authority-1', {
|
||||
protocolVersion: 34,
|
||||
incarnationId: 'authority-incarnation'
|
||||
}),
|
||||
terminalSessionAuthorityAccess: authorityAccess
|
||||
}
|
||||
const current = makeAdapter(34, [session])
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = (await buildHandlerMap()['pty:management:killOne'](
|
||||
{},
|
||||
{ sessionId: 'authority-1' }
|
||||
)) as { success: boolean }
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(current.killAuthorityExact).toHaveBeenCalledWith('authority-1', authorityAccess, {
|
||||
immediate: true
|
||||
})
|
||||
expect(current.killExact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats identity-free inventory as non-destructive', async () => {
|
||||
const current = makeAdapter(32, [makeSession('legacy-1', { incarnationId: undefined })])
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const result = (await buildHandlerMap()['pty:management:killOne'](
|
||||
{},
|
||||
{ sessionId: 'legacy-1' }
|
||||
)) as { success: boolean }
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(current.killExact).not.toHaveBeenCalled()
|
||||
expect(current.killAuthorityExact).not.toHaveBeenCalled()
|
||||
expect(current.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns success=false for unknown sessionId', async () => {
|
||||
const current = makeAdapter(5, [makeSession('new-1')])
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
} from '../daemon/daemon-init'
|
||||
import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health'
|
||||
import type { DaemonSessionInfo } from '../daemon/types'
|
||||
import {
|
||||
killListedPty,
|
||||
listedPtyIdentityKey,
|
||||
type PtyListedKillTarget
|
||||
} from '../providers/pty-listed-session-kill'
|
||||
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
|
||||
|
||||
// Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused".
|
||||
const MAX_POLL_ATTEMPTS = 65
|
||||
@@ -38,7 +44,14 @@ function isDaemonDegraded(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
async function collectSessions(adapters: DaemonPtyAdapter[]): Promise<DaemonSessionInfo[]> {
|
||||
type DaemonSessionInventory = Readonly<{
|
||||
sessions: DaemonSessionInfo[]
|
||||
failedProtocolVersions: number[]
|
||||
}>
|
||||
|
||||
async function collectSessionInventory(
|
||||
adapters: DaemonPtyAdapter[]
|
||||
): Promise<DaemonSessionInventory> {
|
||||
const results = await Promise.allSettled(
|
||||
adapters.map(async (adapter) => {
|
||||
const sessions = await adapter.listSessions()
|
||||
@@ -48,7 +61,56 @@ async function collectSessions(adapters: DaemonPtyAdapter[]): Promise<DaemonSess
|
||||
}))
|
||||
})
|
||||
)
|
||||
return results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))
|
||||
return {
|
||||
sessions: results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])),
|
||||
failedProtocolVersions: results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [adapters[index].protocolVersion] : []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSessions(adapters: DaemonPtyAdapter[]): Promise<DaemonSessionInfo[]> {
|
||||
return (await collectSessionInventory(adapters)).sessions
|
||||
}
|
||||
|
||||
async function collectSessionsExact(adapters: DaemonPtyAdapter[]): Promise<DaemonSessionInfo[]> {
|
||||
const inventory = await collectSessionInventory(adapters)
|
||||
if (inventory.failedProtocolVersions.length > 0) {
|
||||
throw new Error(
|
||||
`pty_management_inventory_unavailable:${inventory.failedProtocolVersions.join(',')}`
|
||||
)
|
||||
}
|
||||
return inventory.sessions
|
||||
}
|
||||
|
||||
type ExactDaemonSessionInfo = DaemonSessionInfo & {
|
||||
terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess
|
||||
mutationRouteToken?: object
|
||||
}
|
||||
|
||||
function daemonSessionTarget(session: ExactDaemonSessionInfo): PtyListedKillTarget {
|
||||
return {
|
||||
id: session.sessionId,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
...(session.terminalSessionAuthorityAccess
|
||||
? { terminalSessionAuthorityAccess: session.terminalSessionAuthorityAccess }
|
||||
: {}),
|
||||
...(session.mutationRouteToken ? { mutationRouteToken: session.mutationRouteToken } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function daemonSessionIdentityKey(session: ExactDaemonSessionInfo): string {
|
||||
return JSON.stringify([
|
||||
session.protocolVersion,
|
||||
listedPtyIdentityKey(daemonSessionTarget(session)) ?? ['unavailable', session.sessionId]
|
||||
])
|
||||
}
|
||||
|
||||
async function killDaemonSession(
|
||||
adapter: DaemonPtyAdapter,
|
||||
session: ExactDaemonSessionInfo
|
||||
): Promise<boolean> {
|
||||
return await killListedPty(adapter, daemonSessionTarget(session), { immediate: true })
|
||||
}
|
||||
|
||||
export function registerDaemonManagementHandlers(): void {
|
||||
@@ -88,9 +150,12 @@ export function registerDaemonManagementHandlers(): void {
|
||||
}> => {
|
||||
const adapters = getDaemonAdapters()
|
||||
// Why: snapshot session IDs up front so mid-kill respawns aren't counted as "remaining".
|
||||
const initial = await collectSessions(adapters)
|
||||
const initialIds = new Set(initial.map((s) => s.sessionId))
|
||||
const initialCount = initial.length
|
||||
const initial = await collectSessionsExact(adapters)
|
||||
const initialByIdentity = new Map(
|
||||
initial.map((session) => [daemonSessionIdentityKey(session), session] as const)
|
||||
)
|
||||
const initialIdentities = new Set(initialByIdentity.keys())
|
||||
const initialCount = initialByIdentity.size
|
||||
|
||||
if (initialCount === 0) {
|
||||
return { killedCount: 0, remainingCount: 0, killedSessionIds: [] }
|
||||
@@ -98,29 +163,28 @@ export function registerDaemonManagementHandlers(): void {
|
||||
|
||||
// Why: no retry — session.kill() is idempotent and runs its own kill ladder; allSettled so one rejection doesn't abort the rest.
|
||||
await Promise.allSettled(
|
||||
initial.map(async (session) => {
|
||||
[...initialByIdentity.values()].map(async (session) => {
|
||||
// Why: assumes PROTOCOL_VERSION stays distinct from PREVIOUS_DAEMON_PROTOCOL_VERSIONS (types.ts), else legacy sessions misroute here.
|
||||
const owner = adapters.find((a) => a.protocolVersion === session.protocolVersion)
|
||||
if (!owner) {
|
||||
return
|
||||
}
|
||||
// Why: immediate=true only matters to legacy/future adapters; swallow rejections since remainingCount reports stuck sessions.
|
||||
await owner.shutdown(session.sessionId, { immediate: true }).catch(() => {})
|
||||
await killDaemonSession(owner, session).catch(() => false)
|
||||
})
|
||||
)
|
||||
|
||||
// Why: count only the initial-snapshot intersection so renderer respawns mid-kill aren't counted as remaining.
|
||||
let remainingOriginalCount = initialCount
|
||||
let remainingOriginalIds = initialIds
|
||||
let remainingOriginalIdentities = initialIdentities
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
const current = await collectSessions(adapters)
|
||||
remainingOriginalIds = new Set(
|
||||
const current = await collectSessionsExact(adapters)
|
||||
remainingOriginalIdentities = new Set(
|
||||
current
|
||||
.filter((session) => initialIds.has(session.sessionId))
|
||||
.map((session) => session.sessionId)
|
||||
.map(daemonSessionIdentityKey)
|
||||
.filter((identity) => initialIdentities.has(identity))
|
||||
)
|
||||
remainingOriginalCount = remainingOriginalIds.size
|
||||
remainingOriginalCount = remainingOriginalIdentities.size
|
||||
if (remainingOriginalCount === 0) {
|
||||
break
|
||||
}
|
||||
@@ -130,9 +194,9 @@ export function registerDaemonManagementHandlers(): void {
|
||||
return {
|
||||
killedCount,
|
||||
remainingCount: remainingOriginalCount,
|
||||
killedSessionIds: [...initialIds].filter(
|
||||
(sessionId) => !remainingOriginalIds.has(sessionId)
|
||||
)
|
||||
killedSessionIds: [...initialByIdentity]
|
||||
.filter(([identity]) => !remainingOriginalIdentities.has(identity))
|
||||
.map(([, session]) => session.sessionId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -144,18 +208,23 @@ export function registerDaemonManagementHandlers(): void {
|
||||
return { success: false }
|
||||
}
|
||||
const adapters = getDaemonAdapters()
|
||||
const sessions = await collectSessions(adapters)
|
||||
const match = sessions.find((s) => s.sessionId === args.sessionId)
|
||||
if (!match) {
|
||||
let sessions: DaemonSessionInfo[]
|
||||
try {
|
||||
sessions = await collectSessionsExact(adapters)
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
const matches = sessions.filter((session) => session.sessionId === args.sessionId)
|
||||
if (matches.length !== 1) {
|
||||
return { success: false }
|
||||
}
|
||||
const match = matches[0]
|
||||
const owner = adapters.find((a) => a.protocolVersion === match.protocolVersion)
|
||||
if (!owner) {
|
||||
return { success: false }
|
||||
}
|
||||
try {
|
||||
await owner.shutdown(args.sessionId, { immediate: true })
|
||||
return { success: true }
|
||||
return { success: await killDaemonSession(owner, match) }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { ExactPtyMutationAccess } from '../../shared/pty-renderer-binding'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import { PtyRendererBindingFenceRegistry } from './pty-renderer-binding-fence'
|
||||
|
||||
const incarnationId = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId
|
||||
|
||||
function access(
|
||||
paneGeneration: number,
|
||||
sequence: number,
|
||||
mutationLeaseId: string
|
||||
): ExactPtyMutationAccess {
|
||||
return {
|
||||
mode: 'exact',
|
||||
identity: { incarnationId, paneGeneration, mutationLeaseId },
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence }
|
||||
}
|
||||
}
|
||||
|
||||
function batch(seq: number, ptyIncarnationId?: string): TerminalSideEffectBatch {
|
||||
return {
|
||||
ptyId: 'pty-1',
|
||||
...(ptyIncarnationId ? { ptyIncarnationId: ptyIncarnationId as PtyIncarnationId } : {}),
|
||||
seq,
|
||||
facts: [{ kind: 'bell' }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('PtyRendererBindingFenceRegistry', () => {
|
||||
it('lets a newer same-generation claimant supersede an overlapping reconnect', () => {
|
||||
const registry = new PtyRendererBindingFenceRegistry()
|
||||
const stale = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 1 }
|
||||
})!
|
||||
const current = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 2 }
|
||||
})!
|
||||
|
||||
expect(registry.owns(stale)).toBe(false)
|
||||
expect(registry.owns(current)).toBe(true)
|
||||
expect(registry.finalize(stale, access(8, 1, 'lease-1'))).toBe(false)
|
||||
expect(registry.finalize(current, access(8, 2, 'lease-2'))).toBe(true)
|
||||
expect(registry.settleExactWithSideEffects('pty-1', access(8, 1, 'lease-1'))).toBeNull()
|
||||
expect(
|
||||
registry.cancelClaimWithSideEffects({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 2 }
|
||||
})
|
||||
).toBeNull()
|
||||
const release = registry.settleExactWithSideEffects('pty-1', access(8, 2, 'lease-2'))!
|
||||
expect(release).toEqual({ id: 'pty-1', ordinal: 2 })
|
||||
expect(registry.isBlocked('pty-1')).toBe(true)
|
||||
expect(registry.drainSideEffectRelease(release, () => true)).toEqual({
|
||||
id: 'pty-1',
|
||||
unresolvedBatchCount: 0
|
||||
})
|
||||
expect(registry.isBlocked('pty-1')).toBe(false)
|
||||
expect(
|
||||
registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 1 }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a newer pane generation fenced against later stale begin and cancel messages', () => {
|
||||
const registry = new PtyRendererBindingFenceRegistry()
|
||||
const current = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 9,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 3 }
|
||||
})!
|
||||
|
||||
expect(
|
||||
registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 99 }
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
registry.cancelClaimWithSideEffects({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 99 }
|
||||
})
|
||||
).toBeNull()
|
||||
expect(registry.owns(current)).toBe(true)
|
||||
expect(registry.isBlocked('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('requires the full incarnation, pane generation, lease, and claimant to settle', () => {
|
||||
const registry = new PtyRendererBindingFenceRegistry()
|
||||
const token = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 4,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 7 }
|
||||
})!
|
||||
const exact = access(4, 7, 'lease-current')
|
||||
|
||||
expect(registry.finalize(token, exact)).toBe(true)
|
||||
expect(registry.settleExactWithSideEffects('pty-1', access(4, 7, 'lease-stale'))).toBeNull()
|
||||
expect(
|
||||
registry.settleExactWithSideEffects('pty-1', {
|
||||
...exact,
|
||||
identity: {
|
||||
...exact.identity,
|
||||
incarnationId: '22222222-2222-4222-8222-222222222222' as PtyIncarnationId
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
expect(registry.isBlocked('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('carries ordered batches into a superseding claim until exact settlement', () => {
|
||||
const registry = new PtyRendererBindingFenceRegistry()
|
||||
const stale = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 4,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 1 }
|
||||
})!
|
||||
expect(registry.retainSideEffect(batch(1, 'incarnation-old'))).toBe('retained')
|
||||
const current = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 5,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 2 }
|
||||
})!
|
||||
expect(registry.retainSideEffect(batch(2, incarnationId))).toBe('retained')
|
||||
|
||||
expect(registry.cancelTokenWithSideEffects(stale)).toBeNull()
|
||||
expect(registry.finalize(current, access(5, 2, 'lease-current'))).toBe(true)
|
||||
const release = registry.settleExactWithSideEffects('pty-1', access(5, 2, 'lease-current'))!
|
||||
const drained: TerminalSideEffectBatch[] = []
|
||||
expect(
|
||||
registry.drainSideEffectRelease(release, (candidate) => {
|
||||
drained.push(candidate)
|
||||
return true
|
||||
})
|
||||
).toEqual({
|
||||
id: 'pty-1',
|
||||
unresolvedBatchCount: 0
|
||||
})
|
||||
expect(drained).toEqual([batch(1, 'incarnation-old'), batch(2, incarnationId)])
|
||||
})
|
||||
|
||||
it('bounds retention and requests backpressure before classifying overflow', () => {
|
||||
const registry = new PtyRendererBindingFenceRegistry({
|
||||
maxSideEffectBatches: 2,
|
||||
maxSideEffectChars: 10_000,
|
||||
sideEffectBackpressureBatches: 1,
|
||||
sideEffectBackpressureChars: 10_000
|
||||
})
|
||||
const token = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 4,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 1 }
|
||||
})!
|
||||
|
||||
expect(registry.retainSideEffect(batch(1, incarnationId))).toBe('backpressure')
|
||||
expect(registry.needsSideEffectBackpressure('pty-1')).toBe(true)
|
||||
expect(registry.retainSideEffect(batch(2, incarnationId))).toBe('backpressure')
|
||||
expect(registry.retainSideEffect(batch(3, incarnationId))).toBe('overflow')
|
||||
const release = registry.cancelTokenWithSideEffects(token)!
|
||||
const drained: TerminalSideEffectBatch[] = []
|
||||
expect(
|
||||
registry.drainSideEffectRelease(release, (candidate) => {
|
||||
drained.push(candidate)
|
||||
return true
|
||||
})
|
||||
).toEqual({
|
||||
id: 'pty-1',
|
||||
unresolvedBatchCount: 1
|
||||
})
|
||||
expect(drained).toEqual([batch(1, incarnationId), batch(2, incarnationId)])
|
||||
expect(registry.needsSideEffectBackpressure('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('commits only a successfully delivered prefix and retains the failed suffix', () => {
|
||||
const registry = new PtyRendererBindingFenceRegistry()
|
||||
const token = registry.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 4,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 1 }
|
||||
})!
|
||||
registry.retainSideEffect(batch(1, incarnationId))
|
||||
registry.retainSideEffect(batch(2, incarnationId))
|
||||
const release = registry.cancelTokenWithSideEffects(token)!
|
||||
|
||||
expect(() =>
|
||||
registry.drainSideEffectRelease(release, (candidate) => {
|
||||
if (candidate.seq === 2) {
|
||||
throw new Error('renderer unavailable')
|
||||
}
|
||||
return true
|
||||
})
|
||||
).toThrow('renderer unavailable')
|
||||
expect(registry.isBlocked('pty-1')).toBe(true)
|
||||
|
||||
const retried: TerminalSideEffectBatch[] = []
|
||||
expect(
|
||||
registry.drainSideEffectRelease(release, (candidate) => {
|
||||
retried.push(candidate)
|
||||
return true
|
||||
})
|
||||
).toEqual({ id: 'pty-1', unresolvedBatchCount: 0 })
|
||||
expect(retried).toEqual([batch(2, incarnationId)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,299 @@
|
||||
import type {
|
||||
ExactPtyMutationAccess,
|
||||
PtyRendererBindingClaim
|
||||
} from '../../shared/pty-renderer-binding'
|
||||
import {
|
||||
comparePtyRendererBindingClaims,
|
||||
exactPtyMutationAccessesEqual,
|
||||
ptyMutationClaimantsEqual
|
||||
} from '../../shared/pty-renderer-binding'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
|
||||
export const PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES = 4_096
|
||||
export const PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_CHARS = 16 * 1024 * 1024
|
||||
export const PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_BATCHES = 1_024
|
||||
export const PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_CHARS = 512 * 1024
|
||||
|
||||
export type PtyRendererBindingFenceToken = Readonly<
|
||||
PtyRendererBindingClaim & {
|
||||
incarnationId?: string
|
||||
ordinal: number
|
||||
}
|
||||
>
|
||||
|
||||
type PtyRendererBindingFence = {
|
||||
token: PtyRendererBindingFenceToken
|
||||
access?: ExactPtyMutationAccess
|
||||
sideEffects: TerminalSideEffectBatch[]
|
||||
sideEffectChars: number
|
||||
unresolvedSideEffectBatches: number
|
||||
releasePrepared: boolean
|
||||
}
|
||||
|
||||
export type PtyRendererBindingSideEffectRelease = Readonly<{
|
||||
id: string
|
||||
ordinal: number
|
||||
}>
|
||||
|
||||
export type PtyRendererBindingSideEffectDrainResult = Readonly<{
|
||||
id: string
|
||||
unresolvedBatchCount: number
|
||||
}>
|
||||
|
||||
export type PtyRendererBindingSideEffectRetention = Readonly<{
|
||||
id: string
|
||||
batches: readonly TerminalSideEffectBatch[]
|
||||
unresolvedBatchCount: number
|
||||
}>
|
||||
|
||||
export type PtyRendererBindingSideEffectRetainResult =
|
||||
| 'unfenced'
|
||||
| 'retained'
|
||||
| 'backpressure'
|
||||
| 'overflow'
|
||||
|
||||
type PtyRendererBindingFenceOptions = {
|
||||
maxSideEffectBatches?: number
|
||||
maxSideEffectChars?: number
|
||||
sideEffectBackpressureBatches?: number
|
||||
sideEffectBackpressureChars?: number
|
||||
}
|
||||
|
||||
function serializedBatchChars(batch: TerminalSideEffectBatch): number {
|
||||
try {
|
||||
return JSON.stringify(batch).length
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
export class PtyRendererBindingFenceRegistry {
|
||||
private readonly fences = new Map<string, PtyRendererBindingFence>()
|
||||
private readonly latestClaims = new Map<string, PtyRendererBindingClaim>()
|
||||
private readonly maxSideEffectBatches: number
|
||||
private readonly maxSideEffectChars: number
|
||||
private readonly sideEffectBackpressureBatches: number
|
||||
private readonly sideEffectBackpressureChars: number
|
||||
private nextOrdinal = 0
|
||||
|
||||
constructor(options: PtyRendererBindingFenceOptions = {}) {
|
||||
this.maxSideEffectBatches =
|
||||
options.maxSideEffectBatches ?? PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES
|
||||
this.maxSideEffectChars =
|
||||
options.maxSideEffectChars ?? PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_CHARS
|
||||
this.sideEffectBackpressureBatches =
|
||||
options.sideEffectBackpressureBatches ?? PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_BATCHES
|
||||
this.sideEffectBackpressureChars =
|
||||
options.sideEffectBackpressureChars ?? PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_CHARS
|
||||
}
|
||||
|
||||
begin(
|
||||
claim: PtyRendererBindingClaim & { incarnationId?: string }
|
||||
): PtyRendererBindingFenceToken | null {
|
||||
const current = this.fences.get(claim.id)
|
||||
const latest = current?.token ?? this.latestClaims.get(claim.id)
|
||||
if (latest) {
|
||||
const comparison = comparePtyRendererBindingClaims(claim, latest)
|
||||
if (comparison < 0) {
|
||||
return null
|
||||
}
|
||||
if (comparison === 0 && current) {
|
||||
return current.token
|
||||
}
|
||||
}
|
||||
this.nextOrdinal += 1
|
||||
const token = Object.freeze({ ...claim, ordinal: this.nextOrdinal })
|
||||
this.fences.set(claim.id, {
|
||||
token,
|
||||
sideEffects: current?.sideEffects ?? [],
|
||||
sideEffectChars: current?.sideEffectChars ?? 0,
|
||||
unresolvedSideEffectBatches: current?.unresolvedSideEffectBatches ?? 0,
|
||||
releasePrepared: false
|
||||
})
|
||||
this.latestClaims.set(claim.id, token)
|
||||
return token
|
||||
}
|
||||
|
||||
owns(token: PtyRendererBindingFenceToken): boolean {
|
||||
return this.fences.get(token.id)?.token === token
|
||||
}
|
||||
|
||||
finalize(token: PtyRendererBindingFenceToken, access: ExactPtyMutationAccess): boolean {
|
||||
const current = this.fences.get(token.id)
|
||||
if (
|
||||
current?.token !== token ||
|
||||
access.identity.paneGeneration !== token.paneGeneration ||
|
||||
!ptyMutationClaimantsEqual(access.claimant, token.claimant)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
current.access = access
|
||||
return true
|
||||
}
|
||||
|
||||
retainSideEffect(batch: TerminalSideEffectBatch): PtyRendererBindingSideEffectRetainResult {
|
||||
const current = this.fences.get(batch.ptyId)
|
||||
if (!current) {
|
||||
return 'unfenced'
|
||||
}
|
||||
const batchChars = serializedBatchChars(batch)
|
||||
if (
|
||||
current.sideEffects.length >= this.maxSideEffectBatches ||
|
||||
batchChars > this.maxSideEffectChars - current.sideEffectChars
|
||||
) {
|
||||
current.unresolvedSideEffectBatches += 1
|
||||
return 'overflow'
|
||||
}
|
||||
current.sideEffects.push(batch)
|
||||
current.sideEffectChars += batchChars
|
||||
return this.needsSideEffectBackpressure(batch.ptyId) ? 'backpressure' : 'retained'
|
||||
}
|
||||
|
||||
needsSideEffectBackpressure(id: string): boolean {
|
||||
const current = this.fences.get(id)
|
||||
return Boolean(
|
||||
current &&
|
||||
(current.releasePrepared ||
|
||||
current.sideEffects.length >= this.sideEffectBackpressureBatches ||
|
||||
current.sideEffectChars >= this.sideEffectBackpressureChars ||
|
||||
current.unresolvedSideEffectBatches > 0)
|
||||
)
|
||||
}
|
||||
|
||||
settleExactWithSideEffects(
|
||||
id: string,
|
||||
access: ExactPtyMutationAccess
|
||||
): PtyRendererBindingSideEffectRelease | null {
|
||||
const current = this.fences.get(id)
|
||||
if (!current?.access || !exactPtyMutationAccessesEqual(current.access, access)) {
|
||||
return null
|
||||
}
|
||||
return this.prepareRelease(current)
|
||||
}
|
||||
|
||||
cancelExactWithSideEffects(
|
||||
id: string,
|
||||
access: ExactPtyMutationAccess
|
||||
): PtyRendererBindingSideEffectRelease | null {
|
||||
const current = this.fences.get(id)
|
||||
if (!current?.access || !exactPtyMutationAccessesEqual(current.access, access)) {
|
||||
return null
|
||||
}
|
||||
return this.prepareRelease(current)
|
||||
}
|
||||
|
||||
cancelClaimWithSideEffects(
|
||||
claim: PtyRendererBindingClaim
|
||||
): PtyRendererBindingSideEffectRelease | null {
|
||||
const current = this.fences.get(claim.id)
|
||||
if (
|
||||
current?.access ||
|
||||
!current ||
|
||||
current.token.paneGeneration !== claim.paneGeneration ||
|
||||
!ptyMutationClaimantsEqual(current.token.claimant, claim.claimant)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return this.prepareRelease(current)
|
||||
}
|
||||
|
||||
cancelTokenWithSideEffects(
|
||||
token: PtyRendererBindingFenceToken
|
||||
): PtyRendererBindingSideEffectRelease | null {
|
||||
const current = this.fences.get(token.id)
|
||||
if (current?.token !== token) {
|
||||
return null
|
||||
}
|
||||
return this.prepareRelease(current)
|
||||
}
|
||||
|
||||
drainSideEffectRelease(
|
||||
release: PtyRendererBindingSideEffectRelease,
|
||||
visit: (batch: TerminalSideEffectBatch) => boolean
|
||||
): PtyRendererBindingSideEffectDrainResult | null {
|
||||
const current = this.fences.get(release.id)
|
||||
if (
|
||||
!current?.releasePrepared ||
|
||||
current.token.ordinal !== release.ordinal ||
|
||||
current.token.id !== release.id
|
||||
) {
|
||||
return null
|
||||
}
|
||||
let processed = 0
|
||||
try {
|
||||
while (processed < current.sideEffects.length) {
|
||||
if (!visit(current.sideEffects[processed])) {
|
||||
current.unresolvedSideEffectBatches += 1
|
||||
}
|
||||
processed += 1
|
||||
}
|
||||
} catch (error) {
|
||||
this.removeProcessedSideEffects(current, processed)
|
||||
throw error
|
||||
}
|
||||
this.fences.delete(release.id)
|
||||
return {
|
||||
id: release.id,
|
||||
unresolvedBatchCount: current.unresolvedSideEffectBatches
|
||||
}
|
||||
}
|
||||
|
||||
abandonSideEffectRelease(
|
||||
release: PtyRendererBindingSideEffectRelease
|
||||
): PtyRendererBindingSideEffectRetention | null {
|
||||
const current = this.fences.get(release.id)
|
||||
if (
|
||||
!current?.releasePrepared ||
|
||||
current.token.ordinal !== release.ordinal ||
|
||||
current.token.id !== release.id
|
||||
) {
|
||||
return null
|
||||
}
|
||||
this.fences.delete(release.id)
|
||||
return this.takeSideEffects(current)
|
||||
}
|
||||
|
||||
isBlocked(id: string): boolean {
|
||||
return this.fences.has(id)
|
||||
}
|
||||
|
||||
clear(id: string): PtyRendererBindingSideEffectRetention | null {
|
||||
this.latestClaims.delete(id)
|
||||
const current = this.fences.get(id)
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
this.fences.delete(id)
|
||||
return this.takeSideEffects(current)
|
||||
}
|
||||
|
||||
clearAll(): PtyRendererBindingSideEffectRetention[] {
|
||||
const retained = Array.from(this.fences.values(), (fence) => this.takeSideEffects(fence))
|
||||
this.fences.clear()
|
||||
this.latestClaims.clear()
|
||||
return retained
|
||||
}
|
||||
|
||||
private takeSideEffects(fence: PtyRendererBindingFence): PtyRendererBindingSideEffectRetention {
|
||||
return {
|
||||
id: fence.token.id,
|
||||
batches: fence.sideEffects,
|
||||
unresolvedBatchCount: fence.unresolvedSideEffectBatches
|
||||
}
|
||||
}
|
||||
|
||||
private prepareRelease(fence: PtyRendererBindingFence): PtyRendererBindingSideEffectRelease {
|
||||
fence.releasePrepared = true
|
||||
return { id: fence.token.id, ordinal: fence.token.ordinal }
|
||||
}
|
||||
|
||||
private removeProcessedSideEffects(fence: PtyRendererBindingFence, count: number): void {
|
||||
if (count === 0) {
|
||||
return
|
||||
}
|
||||
const processed = fence.sideEffects.splice(0, count)
|
||||
for (const batch of processed) {
|
||||
fence.sideEffectChars = Math.max(0, fence.sideEffectChars - serializedBatchChars(batch))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { PtyRendererBindingHeldPauseController } from './pty-renderer-binding-held-pause'
|
||||
|
||||
const incarnationId = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
supportsExactHeldProducerPause: vi.fn(() => true),
|
||||
acquireExactHeldProducerPause: vi.fn(async () => true),
|
||||
releaseExactHeldProducerPause: vi.fn(async () => true)
|
||||
}
|
||||
}
|
||||
|
||||
describe('PtyRendererBindingHeldPauseController', () => {
|
||||
it('uses monotonic provider-scoped tokens and releases committed leases', async () => {
|
||||
const capable = provider()
|
||||
const controller = new PtyRendererBindingHeldPauseController({ onReleaseFailure: vi.fn() })
|
||||
const first = await controller.acquire(capable as never, 'pty-1', incarnationId)
|
||||
const second = await controller.acquire(capable as never, 'pty-2', incarnationId)
|
||||
|
||||
expect(first?.token).toBe('renderer-binding-1')
|
||||
expect(second?.token).toBe('renderer-binding-2')
|
||||
controller.commit(first!)
|
||||
await expect(controller.release('pty-1')).resolves.toBe(true)
|
||||
expect(capable.releaseExactHeldProducerPause).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
incarnationId,
|
||||
'renderer-binding-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a failed predecessor lease for the final retry', async () => {
|
||||
const capable = provider()
|
||||
capable.releaseExactHeldProducerPause.mockResolvedValueOnce(false).mockResolvedValue(true)
|
||||
const onReleaseFailure = vi.fn()
|
||||
const controller = new PtyRendererBindingHeldPauseController({ onReleaseFailure })
|
||||
const first = (await controller.acquire(capable as never, 'pty-1', incarnationId))!
|
||||
const second = (await controller.acquire(capable as never, 'pty-1', incarnationId))!
|
||||
controller.commit(first)
|
||||
controller.commit(second)
|
||||
await vi.waitFor(() => expect(onReleaseFailure).toHaveBeenCalledOnce())
|
||||
|
||||
await expect(controller.release('pty-1')).resolves.toBe(true)
|
||||
expect(capable.releaseExactHeldProducerPause).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
|
||||
const MAX_RENDERER_BINDING_HELD_PAUSES = 4_096
|
||||
|
||||
type ExactHeldPauseProvider = IPtyProvider & {
|
||||
supportsExactHeldProducerPause: (id: string, incarnationId: PtyIncarnationId) => boolean
|
||||
acquireExactHeldProducerPause: (
|
||||
id: string,
|
||||
incarnationId: PtyIncarnationId,
|
||||
token: string
|
||||
) => Promise<boolean>
|
||||
releaseExactHeldProducerPause: (
|
||||
id: string,
|
||||
incarnationId: PtyIncarnationId,
|
||||
token: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
export type PtyRendererBindingHeldPauseLease = Readonly<{
|
||||
id: string
|
||||
incarnationId: PtyIncarnationId
|
||||
token: string
|
||||
provider: ExactHeldPauseProvider
|
||||
}>
|
||||
|
||||
type PtyRendererBindingHeldPauseDeps = {
|
||||
onReleaseFailure: (lease: PtyRendererBindingHeldPauseLease, error?: unknown) => void
|
||||
}
|
||||
|
||||
function exactHeldPauseProvider(
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
incarnationId: PtyIncarnationId
|
||||
): ExactHeldPauseProvider | null {
|
||||
const candidate = provider as Partial<ExactHeldPauseProvider>
|
||||
return typeof candidate.supportsExactHeldProducerPause === 'function' &&
|
||||
typeof candidate.acquireExactHeldProducerPause === 'function' &&
|
||||
typeof candidate.releaseExactHeldProducerPause === 'function' &&
|
||||
candidate.supportsExactHeldProducerPause(id, incarnationId)
|
||||
? (candidate as ExactHeldPauseProvider)
|
||||
: null
|
||||
}
|
||||
|
||||
export class PtyRendererBindingHeldPauseController {
|
||||
private readonly leasesById = new Map<string, Set<PtyRendererBindingHeldPauseLease>>()
|
||||
private readonly nextSerialByProvider = new WeakMap<IPtyProvider, number>()
|
||||
private leaseCount = 0
|
||||
|
||||
constructor(private readonly deps: PtyRendererBindingHeldPauseDeps) {}
|
||||
|
||||
supports(provider: IPtyProvider, id: string, incarnationId: PtyIncarnationId): boolean {
|
||||
return exactHeldPauseProvider(provider, id, incarnationId) !== null
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.leasesById.has(id)
|
||||
}
|
||||
|
||||
async acquire(
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
incarnationId: PtyIncarnationId
|
||||
): Promise<PtyRendererBindingHeldPauseLease | null> {
|
||||
const capable = exactHeldPauseProvider(provider, id, incarnationId)
|
||||
if (!capable || this.leaseCount >= MAX_RENDERER_BINDING_HELD_PAUSES) {
|
||||
return null
|
||||
}
|
||||
const serial = (this.nextSerialByProvider.get(provider) ?? 0) + 1
|
||||
this.nextSerialByProvider.set(provider, serial)
|
||||
const lease = Object.freeze({
|
||||
id,
|
||||
incarnationId,
|
||||
token: `renderer-binding-${serial}`,
|
||||
provider: capable
|
||||
})
|
||||
return (await capable.acquireExactHeldProducerPause(id, incarnationId, lease.token))
|
||||
? lease
|
||||
: null
|
||||
}
|
||||
|
||||
commit(lease: PtyRendererBindingHeldPauseLease): void {
|
||||
let leases = this.leasesById.get(lease.id)
|
||||
if (!leases) {
|
||||
leases = new Set()
|
||||
this.leasesById.set(lease.id, leases)
|
||||
}
|
||||
leases.add(lease)
|
||||
this.leaseCount += 1
|
||||
for (const previous of leases) {
|
||||
if (previous !== lease) {
|
||||
void this.releaseLease(previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async discard(lease: PtyRendererBindingHeldPauseLease): Promise<boolean> {
|
||||
return await this.releaseProviderLease(lease)
|
||||
}
|
||||
|
||||
async release(id: string): Promise<boolean> {
|
||||
const leases = this.leasesById.get(id)
|
||||
if (!leases) {
|
||||
return true
|
||||
}
|
||||
const results = await Promise.all(Array.from(leases, (lease) => this.releaseLease(lease)))
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
for (const id of this.leasesById.keys()) {
|
||||
void this.release(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseLease(lease: PtyRendererBindingHeldPauseLease): Promise<boolean> {
|
||||
const released = await this.releaseProviderLease(lease)
|
||||
if (!released) {
|
||||
return false
|
||||
}
|
||||
const leases = this.leasesById.get(lease.id)
|
||||
if (leases?.delete(lease)) {
|
||||
this.leaseCount -= 1
|
||||
if (leases.size === 0) {
|
||||
this.leasesById.delete(lease.id)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private async releaseProviderLease(lease: PtyRendererBindingHeldPauseLease): Promise<boolean> {
|
||||
try {
|
||||
const released = await lease.provider.releaseExactHeldProducerPause(
|
||||
lease.id,
|
||||
lease.incarnationId,
|
||||
lease.token
|
||||
)
|
||||
if (!released) {
|
||||
this.deps.onReleaseFailure(lease)
|
||||
}
|
||||
return released
|
||||
} catch (error) {
|
||||
this.deps.onReleaseFailure(lease, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { ExactPtyMutationAccess } from '../../shared/pty-renderer-binding'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import { PtyRendererBindingFenceRegistry } from './pty-renderer-binding-fence'
|
||||
import {
|
||||
PtyRendererSideEffectDelivery,
|
||||
type PtyRendererSideEffectBinding
|
||||
} from './pty-renderer-side-effect-delivery'
|
||||
|
||||
const currentIncarnation = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId
|
||||
|
||||
function access(): ExactPtyMutationAccess {
|
||||
return {
|
||||
mode: 'exact',
|
||||
identity: {
|
||||
incarnationId: currentIncarnation,
|
||||
paneGeneration: 7,
|
||||
mutationLeaseId: 'lease-current'
|
||||
},
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 3 }
|
||||
}
|
||||
}
|
||||
|
||||
function batch(seq: number, incarnationId?: string): TerminalSideEffectBatch {
|
||||
return {
|
||||
ptyId: 'pty-1',
|
||||
...(incarnationId ? { ptyIncarnationId: incarnationId as PtyIncarnationId } : {}),
|
||||
seq,
|
||||
facts: [{ kind: 'bell' }]
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
fences = new PtyRendererBindingFenceRegistry(),
|
||||
initialBinding: PtyRendererSideEffectBinding = {
|
||||
mode: 'exact',
|
||||
incarnationId: currentIncarnation
|
||||
}
|
||||
) {
|
||||
let binding = initialBinding
|
||||
const published: TerminalSideEffectBatch[] = []
|
||||
const onPressureChanged = vi.fn()
|
||||
const onReleased = vi.fn()
|
||||
const onReplayDeferred = vi.fn()
|
||||
const onUnresolved = vi.fn()
|
||||
let publishOverride: ((candidate: TerminalSideEffectBatch) => void) | null = null
|
||||
const delivery = new PtyRendererSideEffectDelivery({
|
||||
fences,
|
||||
resolveBinding: () => binding,
|
||||
publish: (candidate) => {
|
||||
if (publishOverride) {
|
||||
publishOverride(candidate)
|
||||
return
|
||||
}
|
||||
published.push(candidate)
|
||||
},
|
||||
onPressureChanged,
|
||||
onReleased,
|
||||
onReplayDeferred,
|
||||
onUnresolved
|
||||
})
|
||||
return {
|
||||
delivery,
|
||||
published,
|
||||
onPressureChanged,
|
||||
onReleased,
|
||||
onReplayDeferred,
|
||||
onUnresolved,
|
||||
setPublish: (next: (candidate: TerminalSideEffectBatch) => void) => {
|
||||
publishOverride = next
|
||||
},
|
||||
setBinding: (next: PtyRendererSideEffectBinding) => {
|
||||
binding = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PtyRendererSideEffectDelivery', () => {
|
||||
it('publishes only the current exact incarnation when no handoff is fenced', () => {
|
||||
const context = setup()
|
||||
|
||||
context.delivery.publish(batch(1, 'incarnation-old'))
|
||||
context.delivery.publish(batch(2))
|
||||
context.delivery.publish(batch(3, currentIncarnation))
|
||||
|
||||
expect(context.published).toEqual([batch(3, currentIncarnation)])
|
||||
expect(context.onUnresolved).toHaveBeenNthCalledWith(1, 'pty-1', 1)
|
||||
expect(context.onUnresolved).toHaveBeenNthCalledWith(2, 'pty-1', 1)
|
||||
})
|
||||
|
||||
it('downgrades identity only for an explicitly legacy binding', () => {
|
||||
const context = setup(new PtyRendererBindingFenceRegistry(), { mode: 'legacy' })
|
||||
|
||||
context.delivery.publish(batch(1, currentIncarnation))
|
||||
context.delivery.publish(batch(2))
|
||||
|
||||
expect(context.published).toEqual([batch(1), batch(2)])
|
||||
expect(context.onUnresolved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains pre-commit ordering and releases only the committed exact incarnation', () => {
|
||||
const fences = new PtyRendererBindingFenceRegistry()
|
||||
const context = setup(fences)
|
||||
const exact = access()
|
||||
const token = fences.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 7,
|
||||
claimant: exact.claimant
|
||||
})!
|
||||
|
||||
context.delivery.publish(batch(1, 'incarnation-old'))
|
||||
context.delivery.publish(batch(2, currentIncarnation))
|
||||
context.delivery.publish(batch(3))
|
||||
context.delivery.publish(batch(4, currentIncarnation))
|
||||
expect(context.published).toEqual([])
|
||||
|
||||
expect(fences.finalize(token, exact)).toBe(true)
|
||||
const retention = fences.settleExactWithSideEffects('pty-1', exact)!
|
||||
context.delivery.releaseExact(retention, exact)
|
||||
|
||||
expect(context.published).toEqual([batch(2, currentIncarnation), batch(4, currentIncarnation)])
|
||||
expect(context.onUnresolved).toHaveBeenCalledWith('pty-1', 2)
|
||||
expect(context.onReleased).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('replays still-current predecessor batches after pre-finalize cancellation', () => {
|
||||
const fences = new PtyRendererBindingFenceRegistry()
|
||||
const context = setup(fences, { mode: 'exact', incarnationId: 'incarnation-old' })
|
||||
const token = fences.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 8,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 4 },
|
||||
incarnationId: 'incarnation-old'
|
||||
})!
|
||||
context.delivery.publish(batch(1, 'incarnation-old'))
|
||||
context.delivery.publish(batch(2, currentIncarnation))
|
||||
|
||||
const retention = fences.cancelTokenWithSideEffects(token)!
|
||||
context.delivery.releaseCurrent(retention)
|
||||
|
||||
expect(context.published).toEqual([batch(1, 'incarnation-old')])
|
||||
expect(context.onUnresolved).toHaveBeenCalledWith('pty-1', 1)
|
||||
})
|
||||
|
||||
it('classifies bounded overflow instead of silently dropping it', () => {
|
||||
const fences = new PtyRendererBindingFenceRegistry({
|
||||
maxSideEffectBatches: 1,
|
||||
maxSideEffectChars: 10_000,
|
||||
sideEffectBackpressureBatches: 1,
|
||||
sideEffectBackpressureChars: 10_000
|
||||
})
|
||||
const context = setup(fences)
|
||||
const token = fences.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 7,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 3 }
|
||||
})!
|
||||
context.delivery.publish(batch(1, currentIncarnation))
|
||||
context.delivery.publish(batch(2, currentIncarnation))
|
||||
|
||||
const release = fences.cancelTokenWithSideEffects(token)!
|
||||
context.delivery.abandonRelease(release)
|
||||
|
||||
expect(context.published).toEqual([])
|
||||
expect(context.onPressureChanged).toHaveBeenCalledWith('pty-1')
|
||||
expect(context.onUnresolved).toHaveBeenCalledWith('pty-1', 2)
|
||||
})
|
||||
|
||||
it('drains reentrant batches before notifying the producer release', () => {
|
||||
const fences = new PtyRendererBindingFenceRegistry()
|
||||
const context = setup(fences)
|
||||
const token = fences.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 7,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 3 }
|
||||
})!
|
||||
context.delivery.publish(batch(1, currentIncarnation))
|
||||
context.setPublish((candidate) => {
|
||||
context.published.push(candidate)
|
||||
if (candidate.seq === 1) {
|
||||
context.delivery.publish(batch(2, currentIncarnation))
|
||||
}
|
||||
})
|
||||
|
||||
context.delivery.releaseCurrent(fences.cancelTokenWithSideEffects(token)!)
|
||||
|
||||
expect(context.published).toEqual([batch(1, currentIncarnation), batch(2, currentIncarnation)])
|
||||
expect(context.onReleased).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries a failed send without duplicating the committed prefix', () => {
|
||||
const fences = new PtyRendererBindingFenceRegistry()
|
||||
const context = setup(fences)
|
||||
const token = fences.begin({
|
||||
id: 'pty-1',
|
||||
paneGeneration: 7,
|
||||
claimant: { rendererEpochId: 'renderer-1', sequence: 3 }
|
||||
})!
|
||||
context.delivery.publish(batch(1, currentIncarnation))
|
||||
context.delivery.publish(batch(2, currentIncarnation))
|
||||
let failSecond = true
|
||||
context.setPublish((candidate) => {
|
||||
if (candidate.seq === 2 && failSecond) {
|
||||
failSecond = false
|
||||
throw new Error('renderer unavailable')
|
||||
}
|
||||
context.published.push(candidate)
|
||||
})
|
||||
|
||||
expect(context.delivery.releaseCurrent(fences.cancelTokenWithSideEffects(token)!)).toBe(false)
|
||||
expect(context.published).toEqual([batch(1, currentIncarnation)])
|
||||
expect(context.onReleased).not.toHaveBeenCalled()
|
||||
expect(context.delivery.retry('pty-1')).toBe(true)
|
||||
expect(context.published).toEqual([batch(1, currentIncarnation), batch(2, currentIncarnation)])
|
||||
expect(context.onReplayDeferred).toHaveBeenCalledOnce()
|
||||
expect(context.onReleased).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { ExactPtyMutationAccess } from '../../shared/pty-renderer-binding'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import type {
|
||||
PtyRendererBindingFenceRegistry,
|
||||
PtyRendererBindingSideEffectRelease,
|
||||
PtyRendererBindingSideEffectRetention
|
||||
} from './pty-renderer-binding-fence'
|
||||
|
||||
export type PtyRendererSideEffectBinding =
|
||||
| { mode: 'exact'; incarnationId: string }
|
||||
| { mode: 'legacy' }
|
||||
| { mode: 'unavailable' }
|
||||
|
||||
type PtyRendererSideEffectDeliveryDeps = {
|
||||
fences: PtyRendererBindingFenceRegistry
|
||||
resolveBinding: (id: string) => PtyRendererSideEffectBinding
|
||||
publish: (batch: TerminalSideEffectBatch) => void
|
||||
onPressureChanged: (id: string) => void
|
||||
onReleased: (id: string) => void
|
||||
onReplayDeferred: (id: string, error: unknown) => void
|
||||
onUnresolved: (id: string, batchCount: number) => void
|
||||
}
|
||||
|
||||
type PendingSideEffectRelease = {
|
||||
release: PtyRendererBindingSideEffectRelease
|
||||
binding: PtyRendererSideEffectBinding | 'current'
|
||||
}
|
||||
|
||||
function asLegacyBatch(batch: TerminalSideEffectBatch): TerminalSideEffectBatch {
|
||||
if (batch.ptyIncarnationId === undefined) {
|
||||
return batch
|
||||
}
|
||||
const { ptyIncarnationId: _unused, ...legacy } = batch
|
||||
void _unused
|
||||
return legacy
|
||||
}
|
||||
|
||||
export class PtyRendererSideEffectDelivery {
|
||||
private readonly pendingReleases = new Map<string, PendingSideEffectRelease>()
|
||||
private readonly drainingReleases = new Set<string>()
|
||||
|
||||
constructor(private readonly deps: PtyRendererSideEffectDeliveryDeps) {}
|
||||
|
||||
publish(batch: TerminalSideEffectBatch): void {
|
||||
const retained = this.deps.fences.retainSideEffect(batch)
|
||||
if (retained !== 'unfenced') {
|
||||
this.deps.onPressureChanged(batch.ptyId)
|
||||
if (this.pendingReleases.has(batch.ptyId) && !this.drainingReleases.has(batch.ptyId)) {
|
||||
this.retry(batch.ptyId)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!this.publishForBinding(batch, this.deps.resolveBinding(batch.ptyId))) {
|
||||
this.reportUnresolved(batch.ptyId, 1)
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.onReplayDeferred(batch.ptyId, error)
|
||||
this.reportUnresolved(batch.ptyId, 1)
|
||||
}
|
||||
}
|
||||
|
||||
releaseExact(
|
||||
release: PtyRendererBindingSideEffectRelease,
|
||||
access: ExactPtyMutationAccess
|
||||
): boolean {
|
||||
return this.prepareRelease(release, {
|
||||
mode: 'exact',
|
||||
incarnationId: access.identity.incarnationId
|
||||
})
|
||||
}
|
||||
|
||||
releaseCurrent(release: PtyRendererBindingSideEffectRelease): boolean {
|
||||
return this.prepareRelease(release, 'current')
|
||||
}
|
||||
|
||||
retry(id: string): boolean {
|
||||
const pending = this.pendingReleases.get(id)
|
||||
if (!pending || this.drainingReleases.has(id)) {
|
||||
return false
|
||||
}
|
||||
const binding = pending.binding === 'current' ? this.deps.resolveBinding(id) : pending.binding
|
||||
if (binding.mode === 'unavailable') {
|
||||
this.deps.onPressureChanged(id)
|
||||
return false
|
||||
}
|
||||
this.drainingReleases.add(id)
|
||||
try {
|
||||
const drained = this.deps.fences.drainSideEffectRelease(pending.release, (batch) =>
|
||||
this.publishForBinding(batch, binding)
|
||||
)
|
||||
if (!drained) {
|
||||
this.pendingReleases.delete(id)
|
||||
return false
|
||||
}
|
||||
this.pendingReleases.delete(id)
|
||||
this.reportUnresolved(id, drained.unresolvedBatchCount)
|
||||
try {
|
||||
this.deps.onReleased(id)
|
||||
} finally {
|
||||
this.deps.onPressureChanged(id)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
this.deps.onReplayDeferred(id, error)
|
||||
this.deps.onPressureChanged(id)
|
||||
return false
|
||||
} finally {
|
||||
this.drainingReleases.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
retryAll(): void {
|
||||
for (const id of this.pendingReleases.keys()) {
|
||||
this.retry(id)
|
||||
}
|
||||
}
|
||||
|
||||
abandonRelease(release: PtyRendererBindingSideEffectRelease): void {
|
||||
this.pendingReleases.delete(release.id)
|
||||
const retention = this.deps.fences.abandonSideEffectRelease(release)
|
||||
if (retention) {
|
||||
this.classifyUnresolved(retention)
|
||||
this.deps.onReleased(release.id)
|
||||
}
|
||||
}
|
||||
|
||||
reset(retentions: readonly PtyRendererBindingSideEffectRetention[]): void {
|
||||
for (const retention of retentions) {
|
||||
this.pendingReleases.delete(retention.id)
|
||||
this.drainingReleases.delete(retention.id)
|
||||
this.deps.onPressureChanged(retention.id)
|
||||
}
|
||||
}
|
||||
|
||||
classifyUnresolved(retention: PtyRendererBindingSideEffectRetention): void {
|
||||
this.pendingReleases.delete(retention.id)
|
||||
this.reportUnresolved(retention.id, retention.batches.length + retention.unresolvedBatchCount)
|
||||
this.deps.onPressureChanged(retention.id)
|
||||
}
|
||||
|
||||
private prepareRelease(
|
||||
release: PtyRendererBindingSideEffectRelease,
|
||||
binding: PtyRendererSideEffectBinding | 'current'
|
||||
): boolean {
|
||||
this.pendingReleases.set(release.id, { release, binding })
|
||||
return this.retry(release.id)
|
||||
}
|
||||
|
||||
private publishForBinding(
|
||||
batch: TerminalSideEffectBatch,
|
||||
binding: PtyRendererSideEffectBinding
|
||||
): boolean {
|
||||
if (binding.mode === 'legacy') {
|
||||
this.deps.publish(asLegacyBatch(batch))
|
||||
return true
|
||||
}
|
||||
if (
|
||||
binding.mode === 'exact' &&
|
||||
batch.ptyIncarnationId !== undefined &&
|
||||
batch.ptyIncarnationId === binding.incarnationId
|
||||
) {
|
||||
this.deps.publish(batch)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private reportUnresolved(id: string, batchCount: number): void {
|
||||
if (batchCount > 0) {
|
||||
this.deps.onUnresolved(id, batchCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user