mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
fix(orcad): survive a slow first daemon start and close review gaps (#25213)
- Give the terminal daemon 30 s to start on Windows and retry the spawn once before falling back, so a cold first deploy is not refused as daemonless. - Say in the activation refusal that orcad.log holds the daemon's error and that the next connect retries. - Managed stop completion now waits out daemon retirement plus the shutdown deadline. - Publish the instance lock atomically and reclaim an abandoned torn lock. - Fix the stop listener closing before it was defined on an already-present request. - `orca serve`'s cache prune keeps the slot a running local orcad uses. - A timed-out daemon retirement reopens admission once it is refused; a retirement that may have reached the daemon stays fenced. - A headless host keeps an editor tab's unsaved draft unless the close is forced. - Unverifiable terminals are attributed by the host's PTY record, like live ones. - An explicit --user-data-dir is no longer overridden on Windows. - Read Windows daemon process identity from the process table, not PowerShell. - Remove the unread data-event incarnationId and daemon health runtime fields, share one process-alive and error-code check, and fold the websocket limits file back. Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -1,13 +1,5 @@
|
||||
/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */
|
||||
export function readDaemonHealthIdentity(): {
|
||||
coverage: 'pty-spawn' | 'handshake'
|
||||
runtimeKind: 'node'
|
||||
runtimeVersion: string
|
||||
} {
|
||||
return {
|
||||
// Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there.
|
||||
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
|
||||
runtimeKind: 'node',
|
||||
runtimeVersion: process.version
|
||||
}
|
||||
/** What a `ptySpawnHealth` reply proves on this platform. */
|
||||
export function ptySpawnHealthPlatformCoverage(): 'pty-spawn' | 'handshake' {
|
||||
// Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there.
|
||||
return process.platform === 'win32' ? 'handshake' : 'pty-spawn'
|
||||
}
|
||||
|
||||
@@ -106,11 +106,9 @@ describe('daemon health', () => {
|
||||
try {
|
||||
await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy')
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
|
||||
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({
|
||||
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({
|
||||
verdict: 'healthy',
|
||||
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
|
||||
runtimeKind: 'node',
|
||||
runtimeVersion: process.version
|
||||
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn'
|
||||
})
|
||||
expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity'
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
type HelloMessage,
|
||||
@@ -24,24 +25,6 @@ export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-u
|
||||
export type DaemonHealthCheck = {
|
||||
verdict: DaemonHealth
|
||||
coverage: 'pty-spawn' | 'handshake'
|
||||
/** Optional runtime proof from newer daemons; absent on mixed-version peers. */
|
||||
runtimeKind?: 'node'
|
||||
runtimeVersion?: string
|
||||
}
|
||||
|
||||
function readRuntimeIdentity(
|
||||
payload: unknown
|
||||
): Pick<DaemonHealthCheck, 'runtimeKind' | 'runtimeVersion'> {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return {}
|
||||
}
|
||||
const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined
|
||||
const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined
|
||||
// Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer.
|
||||
return {
|
||||
...(runtimeKind === 'node' ? { runtimeKind } : {}),
|
||||
...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function readPtySpawnHealthCoverage(
|
||||
@@ -62,7 +45,7 @@ export function checkDaemonHealthWithCoverage(
|
||||
return new Promise((resolve) => {
|
||||
// Older Windows daemons answered this RPC without spawning; an absent optional coverage
|
||||
// field must preserve that weaker meaning during adoption.
|
||||
const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn'
|
||||
const fallbackCoverage = ptySpawnHealthPlatformCoverage()
|
||||
const resolveVerdict = (verdict: DaemonHealth): void =>
|
||||
resolve({ verdict, coverage: fallbackCoverage })
|
||||
if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') {
|
||||
@@ -148,11 +131,9 @@ export function checkDaemonHealthWithCoverage(
|
||||
}
|
||||
|
||||
if (message.id === 'health-1') {
|
||||
const identity = readRuntimeIdentity(message.payload)
|
||||
settle({
|
||||
verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy',
|
||||
coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage),
|
||||
...identity
|
||||
coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,6 +103,22 @@ describe('launchDaemonChild identity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchDaemonChild startup budget', () => {
|
||||
it('waits for readiness past the default 10 s when the caller allows it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const child = fakeDaemonChild(4242)
|
||||
spawnDaemonChildProcessMock.mockReturnValue(child)
|
||||
try {
|
||||
const launch = launchDaemonChild({ ...LAUNCH_OPTIONS, startupTimeoutMs: 30_000 })
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
child.emit('message', { type: 'ready', pid: 4242, startedAtMs: 1_000_000 })
|
||||
await expect(launch).resolves.toMatchObject({ identity: { pid: 4242 } })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchDaemonChild durable-scope fallback', () => {
|
||||
it('retries once without cgroup isolation when the scoped attempt fails', async () => {
|
||||
isDurableDaemonScopeSupportedMock.mockReturnValue(true)
|
||||
|
||||
@@ -14,6 +14,8 @@ export type DaemonChildSpawnOptions = {
|
||||
pidPath: string
|
||||
launchNonce: string
|
||||
macosLoginSessionWatch: boolean
|
||||
/** Defaults to 10 s; a cold first exec on Windows can need longer. */
|
||||
startupTimeoutMs?: number
|
||||
}
|
||||
|
||||
function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { unlinkOwnedDaemonPidFile } from './daemon-spawner'
|
||||
const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000
|
||||
const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000
|
||||
const STARTUP_STDERR_MAX_BYTES = 8192
|
||||
const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000
|
||||
|
||||
export class DaemonEndpointUnavailableError extends Error {
|
||||
constructor(
|
||||
@@ -187,7 +188,7 @@ async function launchDaemonChildAttempt(
|
||||
|
||||
timer = setTimeout(() => {
|
||||
void fail(new Error('Daemon startup timed out'))
|
||||
}, 10000)
|
||||
}, options.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS)
|
||||
|
||||
child.on('message', onReadyMessage)
|
||||
child.on('error', onStartupError)
|
||||
|
||||
@@ -52,9 +52,11 @@ function createPreservedDaemonHandle(
|
||||
return handle
|
||||
}
|
||||
|
||||
export type DaemonLaunchPolicy = { macosLoginSessionWatch?: boolean; startupTimeoutMs?: number }
|
||||
|
||||
export function createOutOfProcessLauncher(
|
||||
runtimeDir: string,
|
||||
macosLoginSessionWatch = false
|
||||
{ macosLoginSessionWatch = false, startupTimeoutMs }: DaemonLaunchPolicy = {}
|
||||
): DaemonLauncher {
|
||||
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
|
||||
const entryPath = getDaemonEntryPath()
|
||||
@@ -132,7 +134,8 @@ export function createOutOfProcessLauncher(
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce,
|
||||
macosLoginSessionWatch
|
||||
macosLoginSessionWatch,
|
||||
startupTimeoutMs
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof DaemonEndpointUnavailableError) || error.reason !== 'occupied') {
|
||||
|
||||
@@ -33,41 +33,40 @@ describe('daemon process inspection', () => {
|
||||
expect(runCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks PowerShell to report a failed CIM query instead of an absent process', async () => {
|
||||
const runCommand = vi.fn(
|
||||
async (_file: string, _args: string[], _timeoutMs: number) =>
|
||||
'{"status":"present","cmd":"daemon","start":1}'
|
||||
)
|
||||
it('reads the Windows process from the process table, not a PowerShell spawn', async () => {
|
||||
const runCommand = vi.fn()
|
||||
const readProcessTable = vi.fn(async () => [
|
||||
{ pid: 42, ppid: 1, name: 'node.exe', command: 'daemon', creationTimeMs: 7 }
|
||||
])
|
||||
|
||||
await queryWindowsProcess(42, { runCommand })
|
||||
|
||||
const script = runCommand.mock.calls[0]?.[1].at(-1) ?? ''
|
||||
expect(script).toContain("$ErrorActionPreference = 'Stop'")
|
||||
expect(script).toMatch(/catch \{[^}]*query_failed/)
|
||||
await expect(queryWindowsProcess(42, { runCommand, readProcessTable })).resolves.toEqual({
|
||||
status: 'present',
|
||||
commandLine: 'daemon',
|
||||
startedAtMs: 7
|
||||
})
|
||||
expect(runCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a failed CIM query indeterminate instead of proving the process gone', async () => {
|
||||
const runCommand = vi.fn(async () => '{"status":"query_failed"}')
|
||||
it('keeps an unreadable process table indeterminate instead of proving the process gone', async () => {
|
||||
const readProcessTable = vi.fn(async () => {
|
||||
throw new Error('windows process table is unreadable')
|
||||
})
|
||||
|
||||
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({
|
||||
await expect(queryWindowsProcess(42, { readProcessTable })).resolves.toEqual({
|
||||
status: 'unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('never reads a probe result without a success marker as proof of absence', async () => {
|
||||
const runCommand = vi.fn(async () => '{"exists":false}')
|
||||
it('reports absence only from a table that was read and lacks the PID', async () => {
|
||||
const readProcessTable = vi.fn(async () => [
|
||||
{ pid: 7, ppid: 1, name: 'other.exe', command: '' }
|
||||
])
|
||||
|
||||
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({
|
||||
status: 'unavailable'
|
||||
await expect(queryWindowsProcess(42, { readProcessTable })).resolves.toEqual({
|
||||
status: 'missing'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports absence only from a CIM query that ran and found nothing', async () => {
|
||||
const runCommand = vi.fn(async () => '{"status":"missing"}')
|
||||
|
||||
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('reads the macOS start time through an async spawn', async () => {
|
||||
const runCommand = vi.fn(async () => 'Sat Jan 1 00:00:00 2028\n')
|
||||
|
||||
@@ -158,14 +157,14 @@ describe('daemon process inspection', () => {
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
|
||||
'rejects unsafe Windows pid %s before command interpolation',
|
||||
'rejects unsafe Windows pid %s before reading the process table',
|
||||
async (pid) => {
|
||||
const runCommand = vi.fn()
|
||||
const readProcessTable = vi.fn(async () => [])
|
||||
|
||||
await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({
|
||||
await expect(queryWindowsProcess(pid, { readProcessTable })).resolves.toEqual({
|
||||
status: 'unavailable'
|
||||
})
|
||||
expect(runCommand).not.toHaveBeenCalled()
|
||||
expect(readProcessTable).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
ProcessSignalEvidence,
|
||||
WindowsProcessEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
import {
|
||||
readWindowsProcessCreationTime,
|
||||
readWindowsProcessTableFresh,
|
||||
type WindowsProcessRow
|
||||
} from '../windows/windows-process-table'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -16,6 +21,7 @@ type InspectionCommandRunner = (file: string, args: string[], timeoutMs: number)
|
||||
export type DaemonProcessInspectionDependencies = {
|
||||
readTextFile?: (path: string) => Promise<string>
|
||||
runCommand?: InspectionCommandRunner
|
||||
readProcessTable?: () => Promise<WindowsProcessRow[]>
|
||||
}
|
||||
|
||||
export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
|
||||
@@ -33,6 +39,12 @@ export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
|
||||
}
|
||||
}
|
||||
|
||||
/** EPERM counts as alive: it proves some process holds the PID. */
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
const signal = inspectProcessSignal(pid)
|
||||
return signal === 'occupied' || signal === 'permission_denied'
|
||||
}
|
||||
|
||||
export function inspectProcessLiveness(pid: number): ProcessLivenessVerdict {
|
||||
const signal = inspectProcessSignal(pid)
|
||||
switch (signal) {
|
||||
@@ -94,9 +106,7 @@ export async function readProcessCommandLine(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Get-CimInstance errors (Winmgmt down, corrupt WMI repository, access denied) are
|
||||
// non-terminating and exit 0 with an empty $p, which is indistinguishable from "no such
|
||||
// process" — so the script reports query failure explicitly instead of asserting absence.
|
||||
// Only a table that was read and lacks the PID proves absence; the reader rejects a truncated one.
|
||||
export async function queryWindowsProcess(
|
||||
pid: number,
|
||||
dependencies: DaemonProcessInspectionDependencies = {}
|
||||
@@ -104,45 +114,21 @@ export async function queryWindowsProcess(
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
const runCommand = dependencies.runCommand ?? runInspectionCommand
|
||||
let rows: WindowsProcessRow[]
|
||||
try {
|
||||
const stdout = await runCommand(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`$ErrorActionPreference = 'Stop'; ` +
|
||||
`try { $p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" } ` +
|
||||
`catch { @{ status = 'query_failed' } | ConvertTo-Json -Compress; exit 0 }; ` +
|
||||
`if (!$p) { @{ status = 'missing' } | ConvertTo-Json -Compress; exit 0 }; ` +
|
||||
`$start = $null; if ($p.CreationDate) { ` +
|
||||
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
|
||||
`@{ status = 'present'; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress`
|
||||
],
|
||||
3_000
|
||||
)
|
||||
const parsed = JSON.parse(stdout.trim()) as {
|
||||
status?: unknown
|
||||
cmd?: unknown
|
||||
start?: unknown
|
||||
}
|
||||
// Only a query that ran and found nothing proves absence; anything else stays indeterminate.
|
||||
if (parsed.status === 'missing') {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
if (parsed.status !== 'present') {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
return {
|
||||
status: 'present',
|
||||
commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null,
|
||||
startedAtMs:
|
||||
typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null
|
||||
}
|
||||
rows = await (dependencies.readProcessTable ?? readWindowsProcessTableFresh)()
|
||||
} catch {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
const row = rows.find((candidate) => candidate.pid === pid)
|
||||
if (!row) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
return {
|
||||
status: 'present',
|
||||
commandLine: row.command || null,
|
||||
startedAtMs: row.creationTimeMs ?? readWindowsProcessCreationTime(pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the sync procfs helper in daemon-process-start-time spawns getconf per call; CLK_TCK is fixed for
|
||||
@@ -219,8 +205,6 @@ async function runInspectionCommand(
|
||||
args: string[],
|
||||
timeoutMs: number
|
||||
): Promise<string> {
|
||||
// powershell.exe is console-subsystem: without this it flashes a conhost and
|
||||
// steals foreground on every inspection (#10488).
|
||||
const { stdout } = await execFileAsync(file, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
@@ -229,6 +213,6 @@ async function runInspectionCommand(
|
||||
return stdout
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
export function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
} from './daemon-launch-paths'
|
||||
import {
|
||||
attributeNextDaemonReplacement,
|
||||
createOutOfProcessLauncher
|
||||
createOutOfProcessLauncher,
|
||||
type DaemonLaunchPolicy
|
||||
} from './daemon-out-of-process-launcher'
|
||||
import type { DaemonProvider } from './daemon-provider-routing'
|
||||
import { installDaemonProvider } from './daemon-provider-state'
|
||||
@@ -46,7 +47,7 @@ function logDaemonMilestone(event: string, details: Record<string, unknown> = {}
|
||||
|
||||
export async function initDaemonPtyProvider(
|
||||
signal?: AbortSignal,
|
||||
options: { macosLoginSessionWatch?: boolean } = {}
|
||||
options: DaemonLaunchPolicy = {}
|
||||
): Promise<void> {
|
||||
logDaemonMilestone('daemon-init-start')
|
||||
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
|
||||
@@ -58,7 +59,7 @@ export async function initDaemonPtyProvider(
|
||||
|
||||
const newSpawner = new DaemonSpawner({
|
||||
runtimeDir,
|
||||
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
|
||||
launcher: createOutOfProcessLauncher(runtimeDir, options)
|
||||
})
|
||||
|
||||
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
|
||||
|
||||
@@ -121,6 +121,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
|
||||
private async finishIdleRetirementRequest(): Promise<DaemonIdleRetirementResult> {
|
||||
try {
|
||||
await this.client.ensureConnected()
|
||||
} catch {
|
||||
// Nothing was asked of the daemon, so nothing can be retiring.
|
||||
this.reopenAfterRefusedIdleRetirement()
|
||||
return { state: 'unverifiable' }
|
||||
}
|
||||
try {
|
||||
const result = await this.client.request<ShutdownIfIdleResult>('shutdownIfIdle', undefined)
|
||||
if (result.retiring) {
|
||||
this.idleRetirementState = 'retiring'
|
||||
@@ -148,7 +154,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
|
||||
|
||||
/** Reopens admission an idle-retirement attempt fenced without retiring the daemon. */
|
||||
releaseIdleRetirementFence(): void {
|
||||
if (this.idleRetirementState !== 'retiring' && !this.idleRetirementPromise) {
|
||||
// An unverifiable attempt may have been accepted, so it stays fenced like a retiring one.
|
||||
if (
|
||||
this.idleRetirementState !== 'retiring' &&
|
||||
this.idleRetirementState !== 'unverifiable' &&
|
||||
!this.idleRetirementPromise
|
||||
) {
|
||||
this.reopenAfterRefusedIdleRetirement()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,16 @@ import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admi
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
|
||||
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
// Checked again in doSpawn: retirement can close admission while spawn awaits.
|
||||
private assertSpawnAdmission(opts: PtySpawnOptions): void {
|
||||
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
|
||||
if (this.idleRetirementAdmissionClosed) {
|
||||
throw new Error('Terminal daemon is decommissioning')
|
||||
}
|
||||
}
|
||||
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
this.assertSpawnAdmission(opts)
|
||||
const spawnOpts = this.withHistoryIsolation(opts)
|
||||
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
|
||||
const operation: PendingDaemonSpawnOperation = {
|
||||
@@ -110,10 +115,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
||||
operation: PendingDaemonSpawnOperation,
|
||||
historyRecovery: HistoryRecoveryContext
|
||||
): Promise<PtySpawnResult> {
|
||||
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
|
||||
if (this.idleRetirementAdmissionClosed) {
|
||||
throw new Error('Terminal daemon is decommissioning')
|
||||
}
|
||||
this.assertSpawnAdmission(opts)
|
||||
if (
|
||||
opts.agentSessionEnsure &&
|
||||
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background
|
||||
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
|
||||
import { readDaemonHealthIdentity } from './daemon-health-identity'
|
||||
import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity'
|
||||
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
||||
import type { TerminalHost } from './terminal-host'
|
||||
import { SessionNotFoundError, type DaemonRequest } from './types'
|
||||
@@ -157,7 +157,7 @@ export class DaemonRequestRouter {
|
||||
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
||||
case 'ptySpawnHealth':
|
||||
await this.options.ptySpawnHealthCheck()
|
||||
return { healthy: true, ...readDaemonHealthIdentity() }
|
||||
return { healthy: true, coverage: ptySpawnHealthPlatformCoverage() }
|
||||
case 'shutdown':
|
||||
return this.shutdown(clientId, request.id, request.payload.killSessions)
|
||||
}
|
||||
|
||||
@@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves PTY incarnation identity through stream serialization', () => {
|
||||
const { batcher, streamSocket } = createBatcher()
|
||||
|
||||
batcher.enqueue('client-1', 'session-1', 'output', {
|
||||
flushImmediately: true,
|
||||
incarnationId: 'incarnation-1'
|
||||
})
|
||||
|
||||
expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({
|
||||
event: 'data',
|
||||
payload: { data: 'output', incarnationId: 'incarnation-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps large pending output batched even when an interactive redraw follows', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher {
|
||||
this.maxLineBytes,
|
||||
sliceSequenceChars,
|
||||
entry.seq,
|
||||
entry.transformed,
|
||||
entry.incarnationId
|
||||
entry.transformed
|
||||
)
|
||||
}
|
||||
this.updateBackpressure(clientId, batch)
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
import { writeStreamDataEvents } from './daemon-stream-data-split'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export type DaemonStreamEnqueueOptions = {
|
||||
flushImmediately?: boolean
|
||||
@@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = {
|
||||
rawLength?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
incarnationId?: PtyIncarnationId
|
||||
}
|
||||
|
||||
export function appendDaemonStreamData(
|
||||
@@ -25,8 +23,7 @@ export function appendDaemonStreamData(
|
||||
last?.sessionId === sessionId &&
|
||||
!last.control &&
|
||||
!last.transformed &&
|
||||
options.transformed !== true &&
|
||||
last.incarnationId === options.incarnationId
|
||||
options.transformed !== true
|
||||
) {
|
||||
last.data += data
|
||||
const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length
|
||||
@@ -42,8 +39,7 @@ export function appendDaemonStreamData(
|
||||
? {}
|
||||
: { sequenceChars: options.rawLength }),
|
||||
...(options.transformed ? { transformed: true } : {}),
|
||||
...(options.seq === undefined ? {} : { seq: options.seq }),
|
||||
...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId })
|
||||
...(options.seq === undefined ? {} : { seq: options.seq })
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -95,8 +91,7 @@ export function flushDaemonStreamSession(
|
||||
maxLineBytes,
|
||||
entry.sequenceChars ?? entry.data.length,
|
||||
entry.seq,
|
||||
entry.transformed,
|
||||
entry.incarnationId
|
||||
entry.transformed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ function write(
|
||||
rawLength = data.length,
|
||||
seq?: number,
|
||||
transformed = false,
|
||||
sessionId = 'session-1',
|
||||
incarnationId?: string
|
||||
sessionId = 'session-1'
|
||||
): string[] {
|
||||
const lines: string[] = []
|
||||
const socket: Pick<Socket, 'write'> = {
|
||||
@@ -28,16 +27,7 @@ function write(
|
||||
return true
|
||||
})
|
||||
}
|
||||
writeStreamDataEvents(
|
||||
socket,
|
||||
sessionId,
|
||||
data,
|
||||
maxLineBytes,
|
||||
rawLength,
|
||||
seq,
|
||||
transformed,
|
||||
incarnationId
|
||||
)
|
||||
writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed)
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => {
|
||||
expect(encodeNdjson).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains incarnation metadata in the single-encode fast path', () => {
|
||||
const line = encodeStreamDataEvent('session-1', 'é🐙', undefined, undefined, false, 'epoch-1')
|
||||
vi.mocked(encodeNdjson).mockClear()
|
||||
expect(
|
||||
write('é🐙', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1')
|
||||
).toEqual([line])
|
||||
expect(encodeNdjson).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not add a duplicate full-data sizing probe to oversized writes', () => {
|
||||
const data = '🐙\x1b[0m'.repeat(100)
|
||||
const expected = previousWrites(data, 160)
|
||||
@@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => {
|
||||
})
|
||||
|
||||
describe('writeStreamDataEvents wire parity', () => {
|
||||
it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => {
|
||||
const data = '🐙é中\x1b[0m"\\\n'.repeat(100)
|
||||
const incarnationId = 'epoch-'.repeat(16)
|
||||
const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId)
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
let consumed = 0
|
||||
const chunks = lines.map((line) => {
|
||||
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384)
|
||||
const { payload } = JSON.parse(line)
|
||||
expect(payload.incarnationId).toBe(incarnationId)
|
||||
expect(typeof payload.data).toBe('string')
|
||||
consumed += payload.data.length
|
||||
expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed)
|
||||
return payload.data
|
||||
})
|
||||
expect(chunks.join('')).toBe(data)
|
||||
})
|
||||
|
||||
it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => {
|
||||
const payloads = [
|
||||
'',
|
||||
|
||||
@@ -5,15 +5,13 @@
|
||||
*/
|
||||
import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export function encodeStreamDataEvent(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
rawLength?: number,
|
||||
seq?: number,
|
||||
transformed?: boolean,
|
||||
incarnationId?: PtyIncarnationId
|
||||
transformed?: boolean
|
||||
): string {
|
||||
return encodeNdjson({
|
||||
type: 'event',
|
||||
@@ -21,7 +19,6 @@ export function encodeStreamDataEvent(
|
||||
sessionId,
|
||||
payload: {
|
||||
data,
|
||||
...(incarnationId === undefined ? {} : { incarnationId }),
|
||||
...(seq === undefined ? {} : { seq }),
|
||||
...(rawLength === undefined ? {} : { rawLength }),
|
||||
...(rawLength === undefined ? {} : { sequenceChars: rawLength }),
|
||||
@@ -30,16 +27,8 @@ export function encodeStreamDataEvent(
|
||||
})
|
||||
}
|
||||
|
||||
function streamDataEventLineBytes(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
rawLength?: number,
|
||||
incarnationId?: PtyIncarnationId
|
||||
): number {
|
||||
return Buffer.byteLength(
|
||||
encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId),
|
||||
'utf8'
|
||||
)
|
||||
function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number {
|
||||
return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8')
|
||||
}
|
||||
|
||||
function isHighSurrogate(value: number): boolean {
|
||||
@@ -90,28 +79,20 @@ export function splitStreamDataForNdjson(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
maxLineBytes: number,
|
||||
sequenceChars?: number,
|
||||
incarnationId?: PtyIncarnationId
|
||||
sequenceChars?: number
|
||||
): string[] {
|
||||
if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) {
|
||||
if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) {
|
||||
return [data]
|
||||
}
|
||||
|
||||
return splitOversizedStreamDataForNdjson(
|
||||
sessionId,
|
||||
data,
|
||||
maxLineBytes,
|
||||
sequenceChars,
|
||||
incarnationId
|
||||
)
|
||||
return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars)
|
||||
}
|
||||
|
||||
function splitOversizedStreamDataForNdjson(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
maxLineBytes: number,
|
||||
sequenceChars?: number,
|
||||
incarnationId?: PtyIncarnationId
|
||||
sequenceChars?: number
|
||||
): string[] {
|
||||
const chunks: string[] = []
|
||||
let start = 0
|
||||
@@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson(
|
||||
}
|
||||
|
||||
if (
|
||||
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <=
|
||||
maxLineBytes
|
||||
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes
|
||||
) {
|
||||
best = mid
|
||||
low = rawMid + 1
|
||||
@@ -154,36 +134,28 @@ export function writeStreamDataEvents(
|
||||
maxLineBytes: number,
|
||||
rawLength = data.length,
|
||||
seq?: number,
|
||||
transformed = false,
|
||||
incarnationId?: PtyIncarnationId
|
||||
transformed = false
|
||||
): void {
|
||||
const explicitRawLength = rawLength === data.length ? undefined : rawLength
|
||||
if (transformed) {
|
||||
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId))
|
||||
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true))
|
||||
return
|
||||
}
|
||||
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
|
||||
let chunks: string[]
|
||||
if (!carriesMetadata) {
|
||||
const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId)
|
||||
const line = encodeStreamDataEvent(sessionId, data)
|
||||
if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) {
|
||||
streamSocket.write(line)
|
||||
return
|
||||
}
|
||||
chunks = splitOversizedStreamDataForNdjson(
|
||||
sessionId,
|
||||
data,
|
||||
maxLineBytes,
|
||||
undefined,
|
||||
incarnationId
|
||||
)
|
||||
chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes)
|
||||
} else {
|
||||
chunks = splitStreamDataForNdjson(
|
||||
sessionId,
|
||||
data,
|
||||
Math.max(1, maxLineBytes - 96),
|
||||
explicitRawLength,
|
||||
incarnationId
|
||||
explicitRawLength
|
||||
)
|
||||
}
|
||||
let consumed = 0
|
||||
@@ -191,8 +163,6 @@ export function writeStreamDataEvents(
|
||||
consumed += chunk.length
|
||||
const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
|
||||
const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
|
||||
streamSocket.write(
|
||||
encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId)
|
||||
)
|
||||
streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry'
|
||||
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
||||
import { SessionOutputPlane } from './session-output-plane'
|
||||
|
||||
function batch(): PendingStreamDataBatch {
|
||||
return {
|
||||
timer: null,
|
||||
queue: [],
|
||||
queuedChars: 0,
|
||||
queuedCharsBySession: new Map(),
|
||||
queuedMetadataBytesBySession: new Map(),
|
||||
droppableQueuedSessionIds: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
describe('daemon stream incarnation ids', () => {
|
||||
it('never merges output from two incarnations into one queued entry', () => {
|
||||
const pending = batch()
|
||||
appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' })
|
||||
appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' })
|
||||
appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' })
|
||||
|
||||
expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([
|
||||
{ data: 'oldmore', incarnationId: 'incarnation-1' },
|
||||
{ data: 'new', incarnationId: 'incarnation-2' }
|
||||
])
|
||||
})
|
||||
|
||||
it('writes the id on per-session flushes and omits it when absent', () => {
|
||||
const pending = batch()
|
||||
appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' })
|
||||
appendDaemonStreamData(pending, 'session-1', 'legacy', {})
|
||||
const lines: string[] = []
|
||||
flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line))
|
||||
|
||||
const payloads = lines.map((line) => JSON.parse(line).payload)
|
||||
expect(payloads).toEqual([
|
||||
{ data: 'tagged', incarnationId: 'incarnation-1' },
|
||||
{ data: 'legacy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('hands the session incarnation only to identity-aware clients', () => {
|
||||
const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' })
|
||||
const legacy = vi.fn()
|
||||
const aware = vi.fn()
|
||||
const unused = vi.fn()
|
||||
plane.attachClient({ onData: legacy, onExit: vi.fn() })
|
||||
plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() })
|
||||
|
||||
plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false })
|
||||
|
||||
expect(legacy).toHaveBeenCalledWith('hi')
|
||||
expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1')
|
||||
expect(unused).not.toHaveBeenCalled()
|
||||
plane.disposeEmulator()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
accountDaemonStreamEntry,
|
||||
releaseDaemonStreamEntry
|
||||
} from './daemon-stream-entry-accounting'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
// A control entry carries a whole pre-shaped stream event (background marker,
|
||||
// data gap, transient fact) that must ride at its exact position in the
|
||||
@@ -28,7 +27,6 @@ export type StreamQueueEntry = {
|
||||
sequenceChars?: number
|
||||
seq?: number
|
||||
transformed?: boolean
|
||||
incarnationId?: PtyIncarnationId
|
||||
control?: DaemonEvent
|
||||
retainedBytes?: number
|
||||
}
|
||||
|
||||
@@ -173,32 +173,23 @@ export class DaemonTerminalAdmission {
|
||||
clientId: string,
|
||||
sessionId: () => string
|
||||
): CreateOrAttachOptions['streamClient'] {
|
||||
const onData = (
|
||||
data: string,
|
||||
rawLength = data.length,
|
||||
transformed = false,
|
||||
seq?: number,
|
||||
incarnationId?: string
|
||||
): void => {
|
||||
const routedSessionId = sessionId()
|
||||
this.options.transientFactRelay.onSessionData(routedSessionId, data)
|
||||
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
|
||||
const isInteractiveOutput =
|
||||
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
|
||||
lastInputAt !== undefined &&
|
||||
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
|
||||
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
|
||||
flushImmediately: isInteractiveOutput,
|
||||
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
|
||||
rawLength,
|
||||
transformed,
|
||||
seq,
|
||||
...(incarnationId === undefined ? {} : { incarnationId })
|
||||
})
|
||||
}
|
||||
return {
|
||||
onData,
|
||||
onDataWithIncarnation: onData,
|
||||
onData: (data, rawLength = data.length, transformed = false, seq) => {
|
||||
const routedSessionId = sessionId()
|
||||
this.options.transientFactRelay.onSessionData(routedSessionId, data)
|
||||
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
|
||||
const isInteractiveOutput =
|
||||
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
|
||||
lastInputAt !== undefined &&
|
||||
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
|
||||
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
|
||||
flushImmediately: isInteractiveOutput,
|
||||
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
|
||||
rawLength,
|
||||
transformed,
|
||||
seq
|
||||
})
|
||||
},
|
||||
onExit: (code, incarnationId, cause) => {
|
||||
const routedSessionId = sessionId()
|
||||
this.options.log.log('session-exited', {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SessionOutputPlane } from './session-output-plane'
|
||||
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
/** The session's ordered output pipeline: the recovery barrier feeding the
|
||||
* output plane. Built together because the barrier's owner is what the
|
||||
@@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: {
|
||||
scrollback?: number | undefined
|
||||
wslDistro?: string | undefined
|
||||
historySeedChunks?: readonly string[] | undefined
|
||||
incarnationId?: PtyIncarnationId | undefined
|
||||
subprocess: SubprocessHandle
|
||||
isAlive: () => boolean
|
||||
}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } {
|
||||
@@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: {
|
||||
scrollback: opts.scrollback,
|
||||
wslDistro: opts.wslDistro,
|
||||
historySeedChunks: opts.historySeedChunks,
|
||||
incarnationId: opts.incarnationId,
|
||||
getTerminalOwner: () => barrier?.getOwner()
|
||||
})
|
||||
const recoveryBarrier = new TerminalShellRecoveryBarrier({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size'
|
||||
import type { PtyIngressEmission } from '../../shared/pty-startup-ingress'
|
||||
import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types'
|
||||
import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint'
|
||||
|
||||
@@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
|
||||
export type AttachedClient = {
|
||||
token: symbol
|
||||
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
|
||||
/** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */
|
||||
onDataWithIncarnation?: (
|
||||
data: string,
|
||||
rawLength: number | undefined,
|
||||
transformed: boolean | undefined,
|
||||
seq: number | undefined,
|
||||
incarnationId: PtyIncarnationId
|
||||
) => void
|
||||
onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void
|
||||
}
|
||||
|
||||
@@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = {
|
||||
scrollback?: number | undefined
|
||||
wslDistro?: string | undefined
|
||||
historySeedChunks?: readonly string[] | undefined
|
||||
incarnationId?: PtyIncarnationId | undefined
|
||||
/** Read from the recovery barrier at snapshot time; the barrier scans bytes
|
||||
* before this plane receives them, so its owner never lags the emulator. */
|
||||
getTerminalOwner?: (() => TerminalOwner | undefined) | undefined
|
||||
@@ -45,7 +35,6 @@ export class SessionOutputPlane {
|
||||
readonly historySeeded: boolean | undefined
|
||||
private readonly emulator: HeadlessEmulator
|
||||
private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined
|
||||
private readonly incarnationId: PtyIncarnationId | undefined
|
||||
private attachedClients: AttachedClient[] = []
|
||||
private pendingOutputRecords: PendingOutputRecord[] = []
|
||||
private pendingOutputBytes = 0
|
||||
@@ -75,7 +64,6 @@ export class SessionOutputPlane {
|
||||
? undefined
|
||||
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))
|
||||
this.readTerminalOwner = opts.getTerminalOwner
|
||||
this.incarnationId = opts.incarnationId
|
||||
}
|
||||
|
||||
get responderParser(): HeadlessEmulator['responderParser'] {
|
||||
@@ -209,7 +197,9 @@ export class SessionOutputPlane {
|
||||
return
|
||||
}
|
||||
this.record({ kind: 'output', data: pending })
|
||||
this.broadcastData(pending, 0, true, this._outputSequence)
|
||||
for (const client of this.attachedClients) {
|
||||
client.onData(pending, 0, true, this._outputSequence)
|
||||
}
|
||||
}
|
||||
|
||||
emit(emission: PtyIngressEmission): void {
|
||||
@@ -226,24 +216,9 @@ export class SessionOutputPlane {
|
||||
}
|
||||
|
||||
// Broadcast to attached clients
|
||||
if (emission.transformed || rawLength !== data.length) {
|
||||
this.broadcastData(data, rawLength, true, this._outputSequence)
|
||||
} else {
|
||||
this.broadcastData(data)
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastData(
|
||||
data: string,
|
||||
rawLength?: number,
|
||||
transformed?: boolean,
|
||||
seq?: number
|
||||
): void {
|
||||
for (const client of this.attachedClients) {
|
||||
if (client.onDataWithIncarnation && this.incarnationId) {
|
||||
client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId)
|
||||
} else if (transformed || rawLength !== undefined || seq !== undefined) {
|
||||
client.onData(data, rawLength, transformed, seq)
|
||||
if (emission.transformed || rawLength !== data.length) {
|
||||
client.onData(data, rawLength, true, this._outputSequence)
|
||||
} else {
|
||||
client.onData(data)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { PtyStartupIngress } from '../../shared/pty-startup-ingress'
|
||||
|
||||
import type * as SessionProtocol from './types'
|
||||
import type {
|
||||
SessionState,
|
||||
ShellReadyState,
|
||||
TakePendingOutputResult,
|
||||
TerminalSnapshot
|
||||
} from './types'
|
||||
import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
|
||||
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
|
||||
|
||||
@@ -23,7 +28,7 @@ export class Session {
|
||||
readonly launchAgent: TuiAgent | null
|
||||
readonly wslDistro: string | null
|
||||
readonly processNameIsSpawnFile: boolean
|
||||
private _state: SessionProtocol.SessionState = 'running'
|
||||
private _state: SessionState = 'running'
|
||||
private _exitCode: number | null = null
|
||||
private _disposed = false
|
||||
private subprocess: SubprocessHandle
|
||||
@@ -50,8 +55,7 @@ export class Session {
|
||||
wslDistro: opts.wslDistro,
|
||||
historySeedChunks: opts.historySeedChunks,
|
||||
subprocess: this.subprocess,
|
||||
isAlive: () => !this._disposed && this._state !== 'exited',
|
||||
incarnationId: this.incarnationId
|
||||
isAlive: () => !this._disposed && this._state !== 'exited'
|
||||
})
|
||||
this.output = pipeline.output
|
||||
this.recoveryBarrier = pipeline.recoveryBarrier
|
||||
@@ -91,11 +95,11 @@ export class Session {
|
||||
this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause))
|
||||
}
|
||||
|
||||
get state(): SessionProtocol.SessionState {
|
||||
get state(): SessionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
get shellState(): SessionProtocol.ShellReadyState {
|
||||
get shellState(): ShellReadyState {
|
||||
return this.shellReady.state
|
||||
}
|
||||
|
||||
@@ -212,7 +216,7 @@ export class Session {
|
||||
this.producerPause.release({ resume: true })
|
||||
}
|
||||
|
||||
getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null {
|
||||
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||
this.startupIngress.snapshotBarrier()
|
||||
return this.output.getSnapshot(opts)
|
||||
}
|
||||
@@ -228,7 +232,7 @@ export class Session {
|
||||
takePendingOutput(
|
||||
includeSnapshot: boolean,
|
||||
opts: { teardownSnapshot?: boolean } = {}
|
||||
): SessionProtocol.TakePendingOutputResult | null {
|
||||
): TakePendingOutputResult | null {
|
||||
if (this._disposed) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -37,13 +37,6 @@ export type CreateOrAttachOptions = {
|
||||
}
|
||||
streamClient: {
|
||||
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
|
||||
onDataWithIncarnation?: (
|
||||
data: string,
|
||||
rawLength: number | undefined,
|
||||
transformed: boolean | undefined,
|
||||
seq: number | undefined,
|
||||
incarnationId: PtyIncarnationId
|
||||
) => void
|
||||
onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void
|
||||
}
|
||||
/** Lets the daemon route output under the adopted owner's canonical id before
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock'
|
||||
import {
|
||||
liveLocalOrcadServeVersion,
|
||||
pruneDesktopOrcadArtifactCache,
|
||||
pruneOrcadArtifactCache
|
||||
} from './orcad-artifact-cache-retention'
|
||||
|
||||
@@ -94,4 +95,27 @@ describe('orcad artifact cache retention', () => {
|
||||
lock('desktop')
|
||||
expect(liveLocalOrcadServeVersion(userData)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps both the selected slot and the one a running orcad serve still uses', async () => {
|
||||
const userData = cacheRoot()
|
||||
const root = join(userData, 'orcad-artifacts')
|
||||
const running = slot(root, 'linux-x64-glibc', '0.1.0+old', 90)
|
||||
const selected = slot(root, 'linux-x64-glibc', '0.4.0+new', 1)
|
||||
const others = [2, 3, 4].map((age) => slot(root, 'linux-x64-glibc', `v${age}`, age))
|
||||
writeFileSync(
|
||||
join(userData, ORCAD_LOCK_FILE_NAME),
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
startedAtMs: null,
|
||||
nonce: 'nonce',
|
||||
identity: 'uid',
|
||||
version: '0.1.0+old',
|
||||
acquiredAt: new Date().toISOString(),
|
||||
role: 'orcad'
|
||||
})
|
||||
)
|
||||
expect(await pruneDesktopOrcadArtifactCache(userData, ['0.4.0+new'])).toEqual([others[2]])
|
||||
expect(existsSync(running)).toBe(true)
|
||||
expect(existsSync(selected)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Bounds the desktop's `<userData>/orcad-artifacts` slot cache, the way VS Code bounds its
|
||||
* server downloads: per target, keep the most recently used slots and evict the rest at
|
||||
* startup. The cache survives uninstall (it lives in userData); this is what keeps it small.
|
||||
* Bounds the desktop's `<userData>/orcad-artifacts` slot cache: per target, keep the most
|
||||
* recently used slots and evict the rest at startup. The cache survives uninstall (it lives in userData); this is what keeps it small.
|
||||
*
|
||||
* Never evicted: a slot this process materialized (an SSH deploy may be reading it) and the
|
||||
* slot a live local orcad serve runs from, named by the profile's instance lock.
|
||||
@@ -11,6 +10,7 @@ import { join } from 'node:path'
|
||||
import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin'
|
||||
import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock'
|
||||
import { materializedOrcadArtifactVersions } from '../ssh/orcad-artifact-materializer'
|
||||
import { isProcessAlive } from '../daemon/daemon-process-inspection'
|
||||
|
||||
/** The in-use slot plus the two most recent others (the newest three when none is in use). */
|
||||
export const ORCAD_ARTIFACT_CACHE_KEEP = 3
|
||||
@@ -68,32 +68,30 @@ export async function pruneOrcadArtifactCache(
|
||||
return removed
|
||||
}
|
||||
|
||||
/** The desktop's startup pass over its own `<userData>/orcad-artifacts`. */
|
||||
export function pruneDesktopOrcadArtifactCache(userDataPath: string): Promise<string[]> {
|
||||
/** A pass over `<userData>/orcad-artifacts` that never evicts a slot something still runs from. */
|
||||
export function pruneDesktopOrcadArtifactCache(
|
||||
userDataPath: string,
|
||||
alsoInUse: readonly string[] = []
|
||||
): Promise<string[]> {
|
||||
const live = liveLocalOrcadServeVersion(userDataPath)
|
||||
return pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), {
|
||||
inUseVersions: new Set([...materializedOrcadArtifactVersions(), ...(live ? [live] : [])])
|
||||
inUseVersions: new Set([
|
||||
...materializedOrcadArtifactVersions(),
|
||||
...(live ? [live] : []),
|
||||
...alsoInUse
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/** The slot version a live local orcad serve runs from, or null when none holds the profile. */
|
||||
export function liveLocalOrcadServeVersion(
|
||||
userDataPath: string,
|
||||
isAlive: (pid: number) => boolean = processIsAlive
|
||||
isAlive: (pid: number) => boolean = isProcessAlive
|
||||
): string | null {
|
||||
const record = readOrcadInstanceLockRecord(join(userDataPath, ORCAD_LOCK_FILE_NAME))
|
||||
return record && record.role !== 'desktop' && isAlive(record.pid) ? record.version : null
|
||||
}
|
||||
|
||||
function processIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return error instanceof Error && 'code' in error && error.code === 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
async function listNames(directory: string): Promise<string[]> {
|
||||
try {
|
||||
return await readdir(directory)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
|
||||
import type { z } from 'zod'
|
||||
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import { writeDurableSecureJsonFile } from '../../shared/secure-file'
|
||||
import { hasErrorCode } from '../daemon/daemon-process-inspection'
|
||||
import {
|
||||
ORCAD_STOP_RECEIPTS_DIRNAME,
|
||||
OrcadCompletedStopReceiptSchema,
|
||||
@@ -40,7 +41,7 @@ export function readOrcadStopReceipt<T extends { request: OrcadManagedStopReques
|
||||
throw new Error('orcad_stop_receipt_unverifiable')
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
||||
if (hasErrorCode(error, 'ENOENT')) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -69,4 +69,22 @@ describe('best-effort daemon retirement', () => {
|
||||
})
|
||||
expect(unanswered.releaseFence).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reopens admission again once a timed-out attempt is refused late', async () => {
|
||||
let answer: (result: DaemonIdleRetirementResult) => void = () => {}
|
||||
const late = ports(() => new Promise((resolve) => (answer = resolve)))
|
||||
await retireOrcadDaemonIfIdle(late)
|
||||
expect(late.releaseFence).toHaveBeenCalledOnce()
|
||||
answer({ state: 'busy', liveSessions: 1 })
|
||||
await vi.waitFor(() => expect(late.releaseFence).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('keeps the fence when a timed-out attempt turns out to retire the daemon', async () => {
|
||||
let answer: (result: DaemonIdleRetirementResult) => void = () => {}
|
||||
const late = ports(() => new Promise((resolve) => (answer = resolve)))
|
||||
await retireOrcadDaemonIfIdle(late)
|
||||
answer({ state: 'retiring' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(late.releaseFence).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
requestIdleDaemonRetirement
|
||||
} from '../daemon/daemon-init'
|
||||
import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request'
|
||||
import { ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS } from './orcad-stop-deadlines'
|
||||
|
||||
export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000
|
||||
|
||||
export type OrcadDaemonRetirement = {
|
||||
retirement: OrcadDaemonRetirementVerdict
|
||||
@@ -48,11 +48,12 @@ export async function retireOrcadDaemonIfIdle(
|
||||
ports: Partial<RetirementPorts> = {}
|
||||
): Promise<OrcadDaemonRetirement> {
|
||||
const { request, releaseFence, countLiveSessions, timeoutMs } = { ...DEFAULT_PORTS, ...ports }
|
||||
const result = await withTimeout(
|
||||
request().catch(() => ({ state: 'unverifiable' as const })),
|
||||
timeoutMs,
|
||||
{ state: 'timed-out' as const }
|
||||
)
|
||||
const attempt = request().catch(() => ({ state: 'unverifiable' as const }))
|
||||
const result = await withTimeout(attempt, timeoutMs, { state: 'timed-out' as const })
|
||||
if (result.state === 'timed-out') {
|
||||
// The fence cannot reopen while the attempt is pending; reopen it once a late refusal lands.
|
||||
void attempt.then((late) => late.state !== 'retiring' && releaseFence())
|
||||
}
|
||||
if (result.state === 'retiring') {
|
||||
return { retirement: 'retired', liveSessions: 0, reason: null }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,20 @@ describe('startOrcadDaemon', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('gives a cold Windows daemon a longer startup budget', async () => {
|
||||
await startOrcadDaemon('win32')
|
||||
expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, {
|
||||
macosLoginSessionWatch: false,
|
||||
startupTimeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('retries the daemon spawn once before falling back to in-process terminals', async () => {
|
||||
initDaemonPtyProviderMock.mockRejectedValueOnce(new Error('Daemon startup timed out'))
|
||||
await expect(startOrcadDaemon()).resolves.toEqual({ state: 'live', pid: 4242 })
|
||||
expect(initDaemonPtyProviderMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports degraded when fresh terminals would fall back to the local provider', async () => {
|
||||
daemonOwnsFreshPersistentPtysMock.mockReturnValue(false)
|
||||
const result = await startOrcadDaemon()
|
||||
@@ -71,6 +85,7 @@ describe('startOrcadDaemon', () => {
|
||||
state: 'unavailable',
|
||||
reason: 'node-pty is missing'
|
||||
})
|
||||
expect(initDaemonPtyProviderMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
readDaemonPidRecord
|
||||
} from '../daemon/daemon-init'
|
||||
|
||||
const WINDOWS_DAEMON_STARTUP_TIMEOUT_MS = 30_000
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export type OrcadDaemonStartup =
|
||||
| { state: 'live'; pid: number | null }
|
||||
| { state: 'degraded'; reason: string }
|
||||
@@ -31,13 +37,25 @@ export type OrcadDaemonStartup =
|
||||
* — `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers
|
||||
* false here without any extra bookkeeping.
|
||||
*/
|
||||
export async function startOrcadDaemon(): Promise<OrcadDaemonStartup> {
|
||||
export async function startOrcadDaemon(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<OrcadDaemonStartup> {
|
||||
// Why no login-session watch: that retires the daemon when the spawning macOS GUI login
|
||||
// session dies. An orcad daemon must survive its SSH session ending — that is the point.
|
||||
const policy = {
|
||||
macosLoginSessionWatch: false,
|
||||
// A freshly uploaded node.exe plus conpty can take well over 10 s on its first, AV-scanned exec.
|
||||
...(platform === 'win32' ? { startupTimeoutMs: WINDOWS_DAEMON_STARTUP_TIMEOUT_MS } : {})
|
||||
}
|
||||
try {
|
||||
// Why no login-session watch: that retires the daemon when the spawning macOS GUI login
|
||||
// session dies. An orcad daemon must survive its SSH session ending — that is the point.
|
||||
await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false })
|
||||
await initDaemonPtyProvider(undefined, policy).catch((error: unknown) => {
|
||||
// One warm retry: activation refuses a daemonless candidate, so a cold-start miss would
|
||||
// otherwise leave a first deploy serving nothing.
|
||||
console.warn(`[orcad] The terminal daemon did not start (${errorMessage(error)}); retrying`)
|
||||
return initDaemonPtyProvider(undefined, policy)
|
||||
})
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
const reason = errorMessage(error)
|
||||
console.error(
|
||||
`[orcad] The terminal daemon did not start: ${reason}\n` +
|
||||
'[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.'
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -146,21 +148,36 @@ describe('acquireOrcadInstanceLock', () => {
|
||||
expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor')
|
||||
})
|
||||
|
||||
it.each([
|
||||
const garbledLocks = [
|
||||
['nothing (a torn write)', ''],
|
||||
['invalid JSON', '{'],
|
||||
['an incomplete record', JSON.stringify({ pid: 424242, identity: 'uid-1000' })],
|
||||
['an invalid pid', JSON.stringify(persistedRecord({ pid: -1 }))],
|
||||
['an invalid start time', JSON.stringify({ ...persistedRecord(), startedAtMs: 'yesterday' })]
|
||||
])('fails closed when the existing lock contains %s', (_label, contents) => {
|
||||
]
|
||||
|
||||
it.each(garbledLocks)('leaves a lock still being written alone: %s', (_label, contents) => {
|
||||
const root = makeRoot()
|
||||
writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), contents)
|
||||
|
||||
expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow(
|
||||
expect.objectContaining({ code: 'orcad_instance_lock_unreadable' })
|
||||
expect.objectContaining({ code: 'orcad_instance_lock_held' })
|
||||
)
|
||||
expect(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toBe(contents)
|
||||
})
|
||||
|
||||
it.each(garbledLocks)('reclaims an abandoned lock containing %s', (_label, contents) => {
|
||||
const root = makeRoot()
|
||||
const lockPath = join(root, ORCAD_LOCK_FILE_NAME)
|
||||
writeFileSync(lockPath, contents)
|
||||
const longAgo = new Date(Date.now() - 60_000)
|
||||
utimesSync(lockPath, longAgo, longAgo)
|
||||
|
||||
const lock = acquireOrcadInstanceLock(root, hooks())
|
||||
expect(JSON.parse(readFileSync(lockPath, 'utf8')).nonce).toBe(lock.record.nonce)
|
||||
expect(readdirSync(root).sort()).toEqual([ORCAD_LOCK_FILE_NAME])
|
||||
})
|
||||
|
||||
it('fails closed when the existing lock is not a regular file', () => {
|
||||
const root = makeRoot()
|
||||
mkdirSync(join(root, ORCAD_LOCK_FILE_NAME))
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* it says nothing about the daemon, which is what makes a non-destructive restart possible.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { linkSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { linkSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
@@ -28,9 +28,12 @@ import {
|
||||
type OrcadDataRootPrivacyHooks
|
||||
} from './orcad-data-root-privacy'
|
||||
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import { hasErrorCode, isProcessAlive } from '../daemon/daemon-process-inspection'
|
||||
|
||||
export const ORCAD_LOCK_FILE_NAME = 'orcad.lock'
|
||||
const MAX_ORCAD_LOCK_BYTES = 64 * 1024
|
||||
// A writer finishes in milliseconds; a garbled record older than this has no live writer.
|
||||
const GARBLED_LOCK_GRACE_MS = 10_000
|
||||
|
||||
export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy'
|
||||
|
||||
@@ -81,19 +84,6 @@ function defaultIdentity(): string {
|
||||
: String(process.getuid?.() ?? 'unknown')
|
||||
}
|
||||
|
||||
function defaultProcessIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return isErrorCode(error, 'EPERM')
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
/** `null` for anything that is not a complete record; never a reason to treat a lock as free. */
|
||||
export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | null {
|
||||
try {
|
||||
@@ -115,7 +105,7 @@ export function acquireOrcadInstanceLock(
|
||||
hooks: OrcadInstanceLockHooks = {}
|
||||
): OrcadInstanceLock {
|
||||
const identity = (hooks.identity ?? defaultIdentity)()
|
||||
const isAlive = hooks.processIsAlive ?? defaultProcessIsAlive
|
||||
const isAlive = hooks.processIsAlive ?? isProcessAlive
|
||||
const readStartedAt = hooks.startedAtMs ?? readOrcadProcessStartedAtMs
|
||||
const matchesStartTime = hooks.startTimeMatches ?? orcadProcessStartTimeMatches
|
||||
|
||||
@@ -144,18 +134,23 @@ export function acquireOrcadInstanceLock(
|
||||
}
|
||||
const serialized = JSON.stringify(record)
|
||||
|
||||
// Staged then hard-linked: the canonical path only ever holds a complete record, never a torn one.
|
||||
const publish = (): boolean => {
|
||||
const staged = `${lockPath}.staged-${process.pid}-${randomUUID()}`
|
||||
try {
|
||||
writeFileSync(lockPath, serialized, { flag: 'wx', mode: 0o600 })
|
||||
writeFileSync(staged, serialized, { flag: 'wx', mode: 0o600 })
|
||||
linkSync(staged, lockPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, 'EEXIST')) {
|
||||
if (hasErrorCode(error, 'EEXIST')) {
|
||||
return false
|
||||
}
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_unusable',
|
||||
`Cannot write the orcad instance lock ${lockPath}: ${(error as Error).message}`
|
||||
)
|
||||
} finally {
|
||||
unlinkQuietly(staged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,16 +158,28 @@ export function acquireOrcadInstanceLock(
|
||||
return makeLock(lockPath, record)
|
||||
}
|
||||
|
||||
const existing = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '')
|
||||
if (!existing) {
|
||||
// Why fail closed: an unreadable record proves nothing about its holder having exited.
|
||||
const existingContents = readBoundedLockFile(lockPath)
|
||||
if (existingContents === null) {
|
||||
// Why fail closed: a record we cannot read proves nothing about its holder having exited.
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_instance_lock_unreadable',
|
||||
`The orcad instance lock at ${lockPath} is unreadable, malformed, or larger than ` +
|
||||
`The orcad instance lock at ${lockPath} is unreadable or larger than ` +
|
||||
`${MAX_ORCAD_LOCK_BYTES} bytes. Refusing to reclaim it without proof that its holder ` +
|
||||
'has exited. Stop orcad and remove the stale lock manually.'
|
||||
)
|
||||
}
|
||||
const existing = parseOrcadInstanceLockRecord(existingContents)
|
||||
if (!existing) {
|
||||
// Only a torn write (an older build, or a crash mid-write) leaves this; once it has aged
|
||||
// past any live writer, no owner can be behind it.
|
||||
if (garbledLockIsAbandoned(lockPath)) {
|
||||
return reclaimAndPublish(lockPath, dataRoot, existingContents, publish, record)
|
||||
}
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_instance_lock_held',
|
||||
`The orcad instance lock at ${lockPath} is still being written by another process. Retry.`
|
||||
)
|
||||
}
|
||||
if (existing.identity !== identity) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_instance_lock_foreign_identity',
|
||||
@@ -189,9 +196,21 @@ export function acquireOrcadInstanceLock(
|
||||
'before starting another, or use a different ORCA_USER_DATA.'
|
||||
)
|
||||
}
|
||||
// Why rename-and-then-publish rather than unlink-and-write: rename claims one exact
|
||||
// directory entry, so a replacement written between our read and our write stays at the
|
||||
// canonical path and wins — we never delete a record we did not inspect.
|
||||
return reclaimAndPublish(lockPath, dataRoot, existingContents, publish, record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why rename-and-then-publish rather than unlink-and-write: rename claims one exact directory
|
||||
* entry, so a replacement written between our read and our write stays at the canonical path
|
||||
* and wins — we never delete a record we did not inspect.
|
||||
*/
|
||||
function reclaimAndPublish(
|
||||
lockPath: string,
|
||||
dataRoot: string,
|
||||
inspectedContents: string,
|
||||
publish: () => boolean,
|
||||
record: OrcadLockRecord
|
||||
): OrcadInstanceLock {
|
||||
const claimPath = `${lockPath}.stale-${process.pid}-${randomUUID()}`
|
||||
try {
|
||||
renameSync(lockPath, claimPath)
|
||||
@@ -204,33 +223,42 @@ export function acquireOrcadInstanceLock(
|
||||
}
|
||||
// A contender may have replaced the entry after the liveness check; never displace its record.
|
||||
const claimedContents = readBoundedLockFile(claimPath)
|
||||
if (parseOrcadInstanceLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) {
|
||||
if (claimedContents !== inspectedContents) {
|
||||
restoreDisplacedLock(claimPath, lockPath, claimedContents)
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_instance_lock_held',
|
||||
`The orcad instance lock at ${lockPath} changed while reclaiming a stale record.`
|
||||
)
|
||||
}
|
||||
if (!publish()) {
|
||||
const published = publish()
|
||||
// A uniquely named claim is inert either way.
|
||||
unlinkQuietly(claimPath)
|
||||
if (!published) {
|
||||
// Someone else claimed it first. Their record is authoritative; ours is not.
|
||||
try {
|
||||
unlinkSync(claimPath)
|
||||
} catch {
|
||||
// A uniquely named claim is inert.
|
||||
}
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_instance_lock_held',
|
||||
`Another orcad took the data root ${dataRoot} while this one was reclaiming a stale lock.`
|
||||
)
|
||||
}
|
||||
try {
|
||||
unlinkSync(claimPath)
|
||||
} catch {
|
||||
// The canonical record is authoritative; the claim is inert.
|
||||
}
|
||||
return makeLock(lockPath, record)
|
||||
}
|
||||
|
||||
function garbledLockIsAbandoned(lockPath: string): boolean {
|
||||
try {
|
||||
return Date.now() - statSync(lockPath).mtimeMs > GARBLED_LOCK_GRACE_MS
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function unlinkQuietly(path: string): void {
|
||||
try {
|
||||
unlinkSync(path)
|
||||
} catch {
|
||||
// Already gone, or inert under its unique name.
|
||||
}
|
||||
}
|
||||
|
||||
function describeLockHolder(record: OrcadLockRecord): string {
|
||||
return record.role === 'desktop' ? 'The Orca desktop app' : 'Another orcad'
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request'
|
||||
import { acquireOrcadInstanceLock } from './orcad-instance-lock'
|
||||
import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime'
|
||||
import { resolveOrcadExitCode } from './orcad-exit-code'
|
||||
import { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-stop-deadlines'
|
||||
import {
|
||||
acquireProfileStateRuntimeAdmission,
|
||||
type ProfileStateRuntimeAdmission
|
||||
@@ -21,7 +22,7 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise<void>): () => Promi
|
||||
}
|
||||
}
|
||||
|
||||
export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000
|
||||
export { ORCAD_SHUTDOWN_DEADLINE_MS }
|
||||
|
||||
/**
|
||||
* A launcher and its child can both receive the same process-group or service stop signal.
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS
|
||||
} from '../../shared/orcad-local-serve-selection'
|
||||
import { selectServeRuntime } from './orcad-local-serve-selection'
|
||||
import { pruneOrcadArtifactCache } from './orcad-artifact-cache-retention'
|
||||
import { pruneDesktopOrcadArtifactCache } from './orcad-artifact-cache-retention'
|
||||
|
||||
function flagValue(argv: readonly string[], flag: string): string | null {
|
||||
const index = argv.indexOf(flag)
|
||||
@@ -34,9 +34,10 @@ async function run(argv: readonly string[]): Promise<void> {
|
||||
usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff)
|
||||
})
|
||||
// orcad serve never starts the desktop's startup pass, so the slot cache is bounded here too.
|
||||
await pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), {
|
||||
inUseVersions: new Set(selection.kind === 'orcad' ? [selection.version] : [])
|
||||
}).catch(() => [])
|
||||
await pruneDesktopOrcadArtifactCache(
|
||||
userDataPath,
|
||||
selection.kind === 'orcad' ? [selection.version] : []
|
||||
).catch(() => [])
|
||||
process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
startTimesWithinTolerance
|
||||
} from '../daemon/daemon-process-start-time'
|
||||
import { readOrcadProcessStartedAtMs } from './orcad-process-start-time'
|
||||
import { hasErrorCode, inspectProcessSignal } from '../daemon/daemon-process-inspection'
|
||||
import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt'
|
||||
import { readOrcadManagedStopDecision } from './orcad-managed-stop-decision'
|
||||
import {
|
||||
@@ -26,6 +27,10 @@ import {
|
||||
orcadManagedStopRequestPath,
|
||||
readOrcadManagedStopRequest
|
||||
} from './orcad-managed-stop-request'
|
||||
import {
|
||||
ORCAD_STOP_COMPLETION_POLL_ATTEMPTS,
|
||||
ORCAD_STOP_COMPLETION_POLL_MS
|
||||
} from './orcad-stop-deadlines'
|
||||
|
||||
export type OrcadProcessProbe = (pid: number) => 'alive' | 'missing' | 'unverifiable'
|
||||
|
||||
@@ -38,14 +43,9 @@ export type OrcadManagedStopCompletionOptions = {
|
||||
}
|
||||
|
||||
function defaultProbe(pid: number): ReturnType<OrcadProcessProbe> {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return 'alive'
|
||||
} catch (error) {
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null
|
||||
// EPERM proves some process holds the PID; it cannot prove ours exited.
|
||||
return code === 'ESRCH' ? 'missing' : 'unverifiable'
|
||||
}
|
||||
const signal = inspectProcessSignal(pid)
|
||||
// EPERM proves some process holds the PID; it cannot prove ours exited.
|
||||
return signal === 'occupied' ? 'alive' : signal === 'missing' ? 'missing' : 'unverifiable'
|
||||
}
|
||||
|
||||
function observeInstance(
|
||||
@@ -75,7 +75,7 @@ export async function completeOrcadManagedStop(
|
||||
options: OrcadManagedStopCompletionOptions = {}
|
||||
): Promise<OrcadManagedStopVerdict> {
|
||||
const request = OrcadManagedStopRequestSchema.parse(input)
|
||||
const attempts = options.attempts ?? 80
|
||||
const attempts = options.attempts ?? ORCAD_STOP_COMPLETION_POLL_ATTEMPTS
|
||||
if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 240) {
|
||||
throw new Error('orcad_managed_stop_invalid_attempts')
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function completeOrcadManagedStop(
|
||||
throw new Error('orcad_managed_stop_request_permissions_unconfirmed')
|
||||
}
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
await (options.sleep ?? (() => delay(250)))()
|
||||
await (options.sleep ?? (() => delay(ORCAD_STOP_COMPLETION_POLL_MS)))()
|
||||
verdict = observeInstance(request.instance, options)
|
||||
if (verdict !== 'live') {
|
||||
return verdict === 'exited' ? completed() : verdict
|
||||
@@ -125,6 +125,6 @@ function existingRequestMatches(path: string, request: OrcadManagedStopRequest):
|
||||
try {
|
||||
return JSON.stringify(readOrcadManagedStopRequest(path)) === JSON.stringify(request)
|
||||
} catch (error) {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
||||
return hasErrorCode(error, 'ENOENT')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,10 @@ import {
|
||||
type OrcadManagedStopRequest
|
||||
} from '../../shared/orcad-stop-request'
|
||||
import { orcadStopReceiptPath, readOrcadStopReceipt } from './orcad-completed-stop-receipt'
|
||||
import { hasErrorCode } from '../daemon/daemon-process-inspection'
|
||||
|
||||
export type OrcadManagedStopDecisionValue = OrcadManagedStopDecision['decision']
|
||||
|
||||
function isAlreadyDecided(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST'
|
||||
}
|
||||
|
||||
export function readOrcadManagedStopDecision(
|
||||
request: OrcadManagedStopRequest
|
||||
): OrcadManagedStopDecisionValue | null {
|
||||
@@ -50,7 +47,7 @@ export function claimOrcadManagedStopDecision(
|
||||
linkSync(staged, path)
|
||||
return decision
|
||||
} catch (error) {
|
||||
if (!isAlreadyDecided(error)) {
|
||||
if (!hasErrorCode(error, 'EEXIST')) {
|
||||
throw error
|
||||
}
|
||||
const standing = readOrcadManagedStopDecision(request)
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
} from './orcad-completed-stop-receipt'
|
||||
import { prepareOrcadManagedStop } from './orcad-managed-stop-admission'
|
||||
import { runOrcadManagedStopCommand } from './orcad-managed-stop-command'
|
||||
import {
|
||||
ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS,
|
||||
ORCAD_SHUTDOWN_DEADLINE_MS,
|
||||
ORCAD_STOP_COMPLETION_POLL_MS
|
||||
} from './orcad-stop-deadlines'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
@@ -165,6 +170,18 @@ describe('completing a managed stop', () => {
|
||||
expect(await completeOrcadManagedStop(request, options())).toBe('live')
|
||||
})
|
||||
|
||||
it('waits out daemon retirement plus the shutdown deadline before answering live', async () => {
|
||||
const { request } = running()
|
||||
let polls = 0
|
||||
const exitAfterMs = 2 * ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS + ORCAD_SHUTDOWN_DEADLINE_MS
|
||||
const verdict = await completeOrcadManagedStop(request, {
|
||||
...options({ attempts: undefined }),
|
||||
sleep: async () => void polls++,
|
||||
probeProcess: () => (polls * ORCAD_STOP_COMPLETION_POLL_MS >= exitAfterMs ? 'missing' : 'alive')
|
||||
})
|
||||
expect(verdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('does not address a live process that no longer holds the lock it published', async () => {
|
||||
const { lock, request } = running()
|
||||
writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' }))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Kept dependency-free: the short-lived stop-completion command imports these too. */
|
||||
export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000
|
||||
export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000
|
||||
|
||||
export const ORCAD_STOP_COMPLETION_POLL_MS = 250
|
||||
// Retirement (request + session count, each bounded) runs before the shutdown deadline starts.
|
||||
const STOP_COMPLETION_BUDGET_MS =
|
||||
2 * ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS + ORCAD_SHUTDOWN_DEADLINE_MS + 5_000
|
||||
export const ORCAD_STOP_COMPLETION_POLL_ATTEMPTS = Math.ceil(
|
||||
STOP_COMPLETION_BUDGET_MS / ORCAD_STOP_COMPLETION_POLL_MS
|
||||
)
|
||||
@@ -112,6 +112,27 @@ describe('orcad stop-request listeners', () => {
|
||||
expect(existsSync(path)).toBe(true)
|
||||
})
|
||||
|
||||
it('acts once on a managed request written before the listeners were installed', async () => {
|
||||
const managedStop = managedContext()
|
||||
writeFileSync(
|
||||
orcadManagedStopRequestPath(managedStop.instance),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a',
|
||||
...managedStop
|
||||
})
|
||||
)
|
||||
const onRequest = vi.fn()
|
||||
const prepare = vi.fn(async () => {})
|
||||
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
listen(onRequest, { installRoot: directory(), managedStop, beforeManagedStop: prepare })
|
||||
await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce())
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(prepare).toHaveBeenCalledOnce()
|
||||
expect(onRequest).toHaveBeenCalledOnce()
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a managed request for another runtime and reports it once', async () => {
|
||||
const managedStop = managedContext()
|
||||
const onRequest = vi.fn()
|
||||
|
||||
@@ -17,20 +17,17 @@ import {
|
||||
validateOrcadManagedStopRequest
|
||||
} from './orcad-managed-stop-request'
|
||||
import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision'
|
||||
import { hasErrorCode } from '../daemon/daemon-process-inspection'
|
||||
|
||||
export type OrcadStopRequestListener = { close(): void }
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1_000
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** `consume` returns true when the request should stop orcad; a missing file throws ENOENT. */
|
||||
function listenForRequest(
|
||||
/** `consume` returns the request that should stop orcad, or null; a missing file throws ENOENT. */
|
||||
function listenForRequest<T>(
|
||||
requestPath: string,
|
||||
consume: (path: string) => boolean,
|
||||
onRequest: () => void,
|
||||
consume: (path: string) => T | null,
|
||||
onRequest: (request: T) => void,
|
||||
pollIntervalMs: number
|
||||
): OrcadStopRequestListener {
|
||||
let closed = false
|
||||
@@ -39,12 +36,11 @@ function listenForRequest(
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
let request: T | null
|
||||
try {
|
||||
if (!consume(requestPath)) {
|
||||
return
|
||||
}
|
||||
request = consume(requestPath)
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
if (!hasErrorCode(error, 'ENOENT')) {
|
||||
// Polling retries every interval; report each distinct refusal once.
|
||||
const failure = error instanceof Error ? error.message : String(error)
|
||||
if (failure !== lastFailure) {
|
||||
@@ -54,9 +50,12 @@ function listenForRequest(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (request === null) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
stop()
|
||||
onRequest()
|
||||
onRequest(request)
|
||||
}
|
||||
let watcher: FSWatcher | null = null
|
||||
try {
|
||||
@@ -98,17 +97,24 @@ export function installOrcadStopRequestListeners(
|
||||
}
|
||||
): OrcadStopRequestListener {
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const listeners = [
|
||||
// Declared first: a listener's initial check can consume a request and close before returning.
|
||||
const listeners: OrcadStopRequestListener[] = []
|
||||
const close = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener.close()
|
||||
}
|
||||
}
|
||||
listeners.push(
|
||||
listenForRequest(
|
||||
join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME),
|
||||
(path) => {
|
||||
unlinkSync(path)
|
||||
return true
|
||||
},
|
||||
onRequest,
|
||||
() => onRequest(),
|
||||
pollIntervalMs
|
||||
)
|
||||
]
|
||||
)
|
||||
const managedStop = options.managedStop
|
||||
if (managedStop) {
|
||||
const prepare = options.beforeManagedStop ?? (async () => {})
|
||||
@@ -121,6 +127,9 @@ export function installOrcadStopRequestListeners(
|
||||
if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') {
|
||||
throw new Error('orcad_managed_stop_canceled')
|
||||
}
|
||||
return request
|
||||
},
|
||||
(request) => {
|
||||
close()
|
||||
// Preparation is best effort: whatever it reports, the stop proceeds.
|
||||
void prepare(request)
|
||||
@@ -128,17 +137,10 @@ export function installOrcadStopRequestListeners(
|
||||
console.error('[orcad] managed stop preparation failed:', error)
|
||||
)
|
||||
.finally(onRequest)
|
||||
return false
|
||||
},
|
||||
onRequest,
|
||||
pollIntervalMs
|
||||
)
|
||||
)
|
||||
}
|
||||
const close = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener.close()
|
||||
}
|
||||
}
|
||||
return { close }
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ function editorTab(
|
||||
return { ...common, type: 'file', language: file.language, mode: 'edit' }
|
||||
}
|
||||
|
||||
export const HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR = 'editor_tab_has_unsaved_draft'
|
||||
|
||||
export function buildHeadlessMobileSessionEditorTabs(
|
||||
worktreeId: string,
|
||||
session: WorkspaceSessionState
|
||||
@@ -73,12 +75,18 @@ export function buildHeadlessMobileSessionEditorTabs(
|
||||
export function retireHeadlessEditorTab(
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string,
|
||||
tab: Pick<HeadlessEditorTab, 'id' | 'filePath'>
|
||||
tab: Pick<HeadlessEditorTab, 'id' | 'filePath'>,
|
||||
force = false
|
||||
): WorkspaceSessionState | null {
|
||||
const files = session.openFilesByWorktree?.[worktreeId] ?? []
|
||||
if (!files.some((file) => file.filePath === tab.filePath)) {
|
||||
const file = files.find((candidate) => candidate.filePath === tab.filePath)
|
||||
if (!file) {
|
||||
return null
|
||||
}
|
||||
// No window here can prompt to save, and the draft lives nowhere else.
|
||||
if (file.dirtyDraftContent !== undefined && !force) {
|
||||
throw new Error(HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR)
|
||||
}
|
||||
const unifiedTabId = editorUnifiedTab(session, worktreeId, tab.filePath)?.id ?? tab.id
|
||||
const withoutTab = (ids: readonly string[] | undefined): string[] | undefined =>
|
||||
ids?.filter((id) => id !== unifiedTabId)
|
||||
@@ -127,12 +135,13 @@ export type HeadlessEditorRetirementHost = {
|
||||
export function retireHeadlessMobileSessionEditorTab(
|
||||
host: HeadlessEditorRetirementHost,
|
||||
worktreeId: string,
|
||||
tab: { id: string; type: string; filePath?: string }
|
||||
tab: { id: string; type: string; filePath?: string },
|
||||
force = false
|
||||
): boolean {
|
||||
const session = host.getWorkspaceSessionForWorktree(worktreeId)
|
||||
const next =
|
||||
session && (tab.type === 'markdown' || tab.type === 'file') && tab.filePath
|
||||
? retireHeadlessEditorTab(session, worktreeId, { id: tab.id, filePath: tab.filePath })
|
||||
? retireHeadlessEditorTab(session, worktreeId, { id: tab.id, filePath: tab.filePath }, force)
|
||||
: null
|
||||
if (!next) {
|
||||
return false
|
||||
|
||||
@@ -306,14 +306,12 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
|
||||
tab,
|
||||
structuredAgentSessionTabCloseCause(options.reason)
|
||||
)
|
||||
} else {
|
||||
if (!this.notifier?.closeSessionTab) {
|
||||
// Why: a headless host listed this editor from its own session, so it retires it there.
|
||||
if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab)) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return finishCommittedClose()
|
||||
} else if (!this.notifier?.closeSessionTab) {
|
||||
// Why: a headless host listed this editor from its own session, so it retires it there.
|
||||
if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab, options.force)) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
} else {
|
||||
await this.notifier.closeSessionTab(tab.id, worktreeId)
|
||||
}
|
||||
return finishCommittedClose()
|
||||
|
||||
@@ -2,133 +2,117 @@ import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationImportResult,
|
||||
OrcadMigrationManifest
|
||||
} from '../../shared/orcad-migration-manifest'
|
||||
OrcadMigrationManifest,
|
||||
} from "../../shared/orcad-migration-manifest";
|
||||
import type {
|
||||
OrcadMigrationSnapshotChunkRequest,
|
||||
OrcadMigrationSnapshotChunkResult
|
||||
} from '../../shared/orcad-migration-scrollback'
|
||||
OrcadMigrationSnapshotChunkResult,
|
||||
} from "../../shared/orcad-migration-scrollback";
|
||||
import {
|
||||
abortStagedOrcadMigrationCatalogDurably,
|
||||
commitStagedOrcadMigrationCatalogDurably,
|
||||
getOrcadMigrationCatalogState,
|
||||
importOrcadMigrationCatalogDurably,
|
||||
stageOrcadMigrationCatalogDurably
|
||||
} from './orcad-migration-catalog-import'
|
||||
import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter'
|
||||
stageOrcadMigrationCatalogDurably,
|
||||
} from "./orcad-migration-catalog-import";
|
||||
import { OrcaRuntimeWithResolveWaiter } from "./orca-runtime-resolve-waiter";
|
||||
import type { RuntimeStore } from "./runtime-store-contract";
|
||||
import type { Store } from "../persistence";
|
||||
|
||||
/** The destination half of a dormant catalog migration; every mutation is flushed before it answers. */
|
||||
export class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithResolveWaiter {
|
||||
async importOrcadMigrationCatalog(
|
||||
manifest: OrcadMigrationManifest,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<OrcadMigrationImportResult> {
|
||||
const store = this.store
|
||||
const importCatalog = store?.importOrcadMigrationCatalog
|
||||
const flushPending = store?.flushPendingOrThrowAsync
|
||||
if (!store || !importCatalog || !flushPending) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return importOrcadMigrationCatalogDurably({
|
||||
store: {
|
||||
importOrcadMigrationCatalog: (input, inputOptions) =>
|
||||
importCatalog.call(store, input, inputOptions),
|
||||
flushPendingOrThrowAsync: (flushOptions) => flushPending.call(store, flushOptions)
|
||||
},
|
||||
store: this.requireMigrationStore(
|
||||
"importOrcadMigrationCatalog",
|
||||
"flushPendingOrThrowAsync",
|
||||
),
|
||||
manifest,
|
||||
signal: options.signal,
|
||||
onDurableImport: () => {
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
})
|
||||
this.invalidateResolvedWorktreeCache();
|
||||
this.notifyReposChanged();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async stageOrcadMigrationCatalog(
|
||||
manifest: OrcadMigrationManifest,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<OrcadMigrationCatalogState> {
|
||||
const store = this.store
|
||||
const stage = store?.stageOrcadMigrationCatalog
|
||||
const flush = store?.flushPendingOrThrowAsync
|
||||
if (!store || !stage || !flush) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return stageOrcadMigrationCatalogDurably({
|
||||
store: {
|
||||
stageOrcadMigrationCatalog: (input, inputOptions) => stage.call(store, input, inputOptions),
|
||||
flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions)
|
||||
},
|
||||
store: this.requireMigrationStore(
|
||||
"stageOrcadMigrationCatalog",
|
||||
"flushPendingOrThrowAsync",
|
||||
),
|
||||
manifest,
|
||||
signal: options.signal
|
||||
})
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async commitStagedOrcadMigrationCatalog(
|
||||
manifest: OrcadMigrationManifest,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<OrcadMigrationCatalogState> {
|
||||
const store = this.store
|
||||
const commit = store?.commitStagedOrcadMigrationCatalog
|
||||
const flush = store?.flushPendingOrThrowAsync
|
||||
if (!store || !commit || !flush) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return commitStagedOrcadMigrationCatalogDurably({
|
||||
store: {
|
||||
commitStagedOrcadMigrationCatalog: (input, inputOptions) =>
|
||||
commit.call(store, input, inputOptions),
|
||||
flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions)
|
||||
},
|
||||
store: this.requireMigrationStore(
|
||||
"commitStagedOrcadMigrationCatalog",
|
||||
"flushPendingOrThrowAsync",
|
||||
),
|
||||
manifest,
|
||||
signal: options.signal,
|
||||
onDurableCommit: () => {
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
})
|
||||
this.invalidateResolvedWorktreeCache();
|
||||
this.notifyReposChanged();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
stageOrcadMigrationSnapshotChunk(
|
||||
request: OrcadMigrationSnapshotChunkRequest
|
||||
request: OrcadMigrationSnapshotChunkRequest,
|
||||
): OrcadMigrationSnapshotChunkResult {
|
||||
const store = this.store
|
||||
const stageChunk = store?.stageOrcadMigrationSnapshotChunk
|
||||
if (!store || !stageChunk) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return stageChunk.call(store, request)
|
||||
return this.requireMigrationStore(
|
||||
"stageOrcadMigrationSnapshotChunk",
|
||||
).stageOrcadMigrationSnapshotChunk(request);
|
||||
}
|
||||
|
||||
async abortStagedOrcadMigrationCatalog(
|
||||
manifest: OrcadMigrationManifest,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<OrcadMigrationCatalogAbortResult> {
|
||||
const store = this.store
|
||||
const abort = store?.abortStagedOrcadMigrationCatalog
|
||||
const flush = store?.flushPendingOrThrowAsync
|
||||
if (!store || !abort || !flush) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return abortStagedOrcadMigrationCatalogDurably({
|
||||
store: {
|
||||
abortStagedOrcadMigrationCatalog: (input) => abort.call(store, input),
|
||||
flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions)
|
||||
},
|
||||
store: this.requireMigrationStore(
|
||||
"abortStagedOrcadMigrationCatalog",
|
||||
"flushPendingOrThrowAsync",
|
||||
),
|
||||
manifest,
|
||||
signal: options.signal
|
||||
})
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState {
|
||||
const store = this.store
|
||||
const readState = store?.getOrcadMigrationCatalogState
|
||||
if (!store || !readState) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
getOrcadMigrationCatalogState(
|
||||
manifest: OrcadMigrationManifest,
|
||||
): OrcadMigrationCatalogState {
|
||||
return getOrcadMigrationCatalogState({
|
||||
store: { getOrcadMigrationCatalogState: (input) => readState.call(store, input) },
|
||||
manifest
|
||||
})
|
||||
store: this.requireMigrationStore("getOrcadMigrationCatalogState"),
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
|
||||
/** A partial store (tests, headless hosts) lacks some optional methods; refuse rather than half-run. */
|
||||
private requireMigrationStore<K extends keyof RuntimeStore & keyof Store>(
|
||||
...methods: K[]
|
||||
): Pick<Store, K> {
|
||||
const store = this.store;
|
||||
if (
|
||||
!store ||
|
||||
methods.some((method) => typeof store[method] !== "function")
|
||||
) {
|
||||
throw new Error("runtime_unavailable");
|
||||
}
|
||||
return this.requireStore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,22 @@ describe('editor tabs a headless host persisted', () => {
|
||||
expect(getSession().unifiedTabs?.[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an unsaved draft unless the close is forced', async () => {
|
||||
const session = sessionWithMigratedEditor()
|
||||
const [file] = session.openFilesByWorktree?.[TEST_WORKTREE_ID] ?? []
|
||||
session.openFilesByWorktree = { [TEST_WORKTREE_ID]: [{ ...file, dirtyDraftContent: 'draft' }] }
|
||||
const { runtime, getSession } = headlessRuntime(session)
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
await expect(
|
||||
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme')
|
||||
).rejects.toThrow('editor_tab_has_unsaved_draft')
|
||||
expect(getSession().openFilesByWorktree?.[TEST_WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'draft'
|
||||
)
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme', { force: true })
|
||||
expect(getSession().openFilesByWorktree?.[TEST_WORKTREE_ID]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024
|
||||
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
|
||||
export const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128
|
||||
// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound.
|
||||
export const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
type WebSocketMessageHandler
|
||||
} from './node-websocket-lifecycle'
|
||||
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
|
||||
import {
|
||||
WEBSOCKET_TRANSPORT_MAX_CONNECTIONS,
|
||||
WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES,
|
||||
WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS
|
||||
} from './websocket-transport-limits'
|
||||
|
||||
const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024
|
||||
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
|
||||
const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128
|
||||
// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound.
|
||||
const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2
|
||||
|
||||
const PRE_AUTH_TIMEOUT_MS = 10_000
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import { applyRuntimeWorktreePsUnverifiableTerminals } from './runtime-worktree-ps-unverifiable-terminals'
|
||||
|
||||
describe('unverifiable terminal attribution', () => {
|
||||
it('follows the host PTY record, not a stale pane, as the live pass does', () => {
|
||||
const summaries = new Map<string, Partial<RuntimeWorktreePsSummary>>([
|
||||
['wt-old', {}],
|
||||
['wt-new', {}]
|
||||
])
|
||||
applyRuntimeWorktreePsUnverifiableTerminals({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the counters this pass writes are read back.
|
||||
summaries: summaries as Map<string, RuntimeWorktreePsSummary>,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getSummary below ignores the index.
|
||||
pathIndex: {} as never,
|
||||
missingIds: new Set(),
|
||||
countedPtyIds: new Set(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
|
||||
leaves: [{ ptyId: 'pty-1', worktreeId: 'wt-old' } as never],
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
|
||||
ptysById: new Map([['pty-1', { ptyId: 'pty-1', worktreeId: 'wt-new' } as never]]),
|
||||
getLivenessVerdict: () => ({ status: 'unverifiable', reason: 'lost contact' }),
|
||||
getSummary: (all, _index, _missing, worktreeId) => all.get(worktreeId) ?? null
|
||||
})
|
||||
expect(summaries.get('wt-new')?.unverifiableTerminalCount).toBe(1)
|
||||
expect(summaries.get('wt-old')?.unverifiableTerminalCount).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -23,18 +23,16 @@ export function applyRuntimeWorktreePsUnverifiableTerminals(args: {
|
||||
worktreeId: string
|
||||
) => RuntimeWorktreePsSummary | null
|
||||
}): void {
|
||||
// The renderer's pane owns the PTY's worktree; the record is the fallback once no pane holds it.
|
||||
// Like the live pass, the host's PTY record owns the worktree; a pane only fills in a PTY with no record.
|
||||
const ownerByPtyId = new Map<string, string>()
|
||||
for (const pty of args.ptysById.values()) {
|
||||
ownerByPtyId.set(pty.ptyId, pty.worktreeId)
|
||||
}
|
||||
for (const leaf of args.leaves) {
|
||||
if (leaf.ptyId && !ownerByPtyId.has(leaf.ptyId)) {
|
||||
ownerByPtyId.set(leaf.ptyId, leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const pty of args.ptysById.values()) {
|
||||
if (!ownerByPtyId.has(pty.ptyId)) {
|
||||
ownerByPtyId.set(pty.ptyId, pty.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [ptyId, worktreeId] of ownerByPtyId) {
|
||||
if (
|
||||
args.countedPtyIds.has(ptyId) ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { evaluateOrcadActivation } from './orcad-activation-gate'
|
||||
import { classifyOrcadHostUnavailable } from './orcad-host-unavailable'
|
||||
import type { ServeReadiness } from '../server/serve-readiness'
|
||||
import type { OrcadHealth, TerminalDaemonHealth } from '../orcad/orcad-health'
|
||||
|
||||
@@ -85,6 +86,8 @@ describe('evaluateOrcadActivation', () => {
|
||||
EXPECTED
|
||||
)
|
||||
expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_absent' })
|
||||
// A cold daemon start is transient: the host must stay eligible for the next connect's deploy.
|
||||
expect(classifyOrcadHostUnavailable({ code: 'orcad_activation_daemon_absent' })).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a degraded daemon, whose fresh terminals would not survive a restart', () => {
|
||||
|
||||
@@ -105,7 +105,8 @@ export function evaluateOrcadActivation(
|
||||
reason:
|
||||
'The candidate has no terminal daemon. Every terminal on this host would run in the ' +
|
||||
'orcad process and die with it, which is the exact regression the daemon exists to ' +
|
||||
'prevent. Nothing was activated.'
|
||||
"prevent. Nothing was activated; orcad.log carries the daemon's startup error, and " +
|
||||
'the next connect retries.'
|
||||
}
|
||||
}
|
||||
if (daemon.state === 'degraded') {
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
|
||||
const ERROR_MESSAGE = /could not find the Windows roaming AppData folder/
|
||||
|
||||
function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & {
|
||||
function createHost(
|
||||
nativeAppData: string | Error,
|
||||
switches: string[] = []
|
||||
): WindowsAppDataPathHost & {
|
||||
calls: string[]
|
||||
paths: Map<string, string>
|
||||
} {
|
||||
@@ -21,6 +24,7 @@ function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & {
|
||||
calls,
|
||||
paths,
|
||||
getName: () => 'orca',
|
||||
commandLine: { hasSwitch: (name) => switches.includes(name) },
|
||||
getPath: (name) => {
|
||||
calls.push(`get:${name}`)
|
||||
const overridden = paths.get(name)
|
||||
@@ -85,6 +89,12 @@ describe('ensureWindowsAppDataPath', () => {
|
||||
expect(host.paths.get('userData')).toBe('C:\\Users\\me\\AppData\\Roaming\\orca')
|
||||
})
|
||||
|
||||
it('leaves userData to an explicit --user-data-dir switch', () => {
|
||||
const host = createHost('C:\\Users\\me\\AppData\\Roaming', ['user-data-dir'])
|
||||
ensureWindowsAppDataPath(host, {}, 'win32')
|
||||
expect(host.calls).toEqual(['get:appData'])
|
||||
})
|
||||
|
||||
it('creates and sets appData from the environment before userData when the lookup throws', () => {
|
||||
// Why a real dir: the fallback must mkdir a folder that a profile-less session never created.
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-appdata-'))
|
||||
|
||||
@@ -5,6 +5,7 @@ export type WindowsAppDataPathHost = {
|
||||
getPath(name: 'appData'): string
|
||||
setPath(name: 'appData' | 'userData', value: string): void
|
||||
getName(): string
|
||||
commandLine: { hasSwitch(name: string): boolean }
|
||||
}
|
||||
|
||||
/** Derives the roaming AppData folder from the environment when the known-folder lookup fails. */
|
||||
@@ -50,6 +51,10 @@ export function ensureWindowsAppDataPath(
|
||||
mkdirSync(appData, { recursive: true })
|
||||
host.setPath('appData', appData)
|
||||
}
|
||||
// Electron already resolved userData from an explicit switch; pinning it would override that.
|
||||
if (host.commandLine.hasSwitch('user-data-dir')) {
|
||||
return
|
||||
}
|
||||
// Same value Electron's own provider computes; setting it skips that provider entirely.
|
||||
host.setPath('userData', win32.join(appData, host.getName()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user