mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3491-osascript-automation
# Conflicts: # src/main/daemon/daemon-health.ts # src/main/daemon/daemon-init.test.ts # src/main/daemon/daemon-init.ts
This commit is contained in:
@@ -20,6 +20,7 @@ on:
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- 'config/scripts/daemon-boot-smoke.mjs'
|
||||
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
|
||||
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
# Why: the native-smoke job boots the built terminal daemon under plain
|
||||
@@ -135,6 +136,11 @@ jobs:
|
||||
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
|
||||
- name: Daemon boot smoke
|
||||
run: node config/scripts/daemon-boot-smoke.mjs
|
||||
# Why: a daemon that lost its endpoint name used to delete the replacement's
|
||||
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
|
||||
# Only two real processes racing the same endpoint reproduce it.
|
||||
- name: Daemon endpoint handover smoke
|
||||
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
||||
# Why: workspace removal overlaps graceful renderer teardown with the
|
||||
# forced main-process sweep; exercise that exact pair against real ConPTY.
|
||||
- name: Windows daemon workspace-close repro
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
@@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) {
|
||||
return join(userDataDir, 'daemon.sock')
|
||||
}
|
||||
|
||||
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
|
||||
// Connects a single control socket, completes the hello handshake, and calls
|
||||
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
|
||||
// true on success, false on any failure — never throws.
|
||||
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
return new Promise((resolveCheck) => {
|
||||
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
|
||||
return new Promise((resolveRpc, rejectRpc) => {
|
||||
let settled = false
|
||||
let buffer = ''
|
||||
const socket = connect(socketPath)
|
||||
const finish = (ok, reason) => {
|
||||
const finish = (error, response) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
if (!ok && reason) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${reason}`)
|
||||
if (error) {
|
||||
rejectRpc(error)
|
||||
} else {
|
||||
resolveRpc(response)
|
||||
}
|
||||
resolveCheck(ok)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
|
||||
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
|
||||
|
||||
socket.on('error', (err) => finish(false, err.message))
|
||||
socket.on('error', (error) => finish(error))
|
||||
socket.on('connect', () => {
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
socket.write(
|
||||
@@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
finish(false, 'invalid response line')
|
||||
finish(new Error('invalid response line'))
|
||||
return
|
||||
}
|
||||
if (msg.type === 'hello') {
|
||||
if (!msg.ok) {
|
||||
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
|
||||
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
|
||||
return
|
||||
}
|
||||
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
|
||||
} else if (msg.id === 'health-1') {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
} else if (msg.id === request.id) {
|
||||
finish(
|
||||
msg.ok === true,
|
||||
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
|
||||
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
|
||||
msg
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
})
|
||||
}
|
||||
|
||||
// Best-effort: constrained CI runners can make node-pty spawn flaky.
|
||||
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
try {
|
||||
await runDaemonRpc(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
{ id: 'health-1', type: 'ptySpawnHealth' },
|
||||
PTY_HEALTH_TIMEOUT_MS
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
|
||||
const socketPath = makeSocketPath(userDataDir)
|
||||
const tokenPath = join(userDataDir, 'daemon.token')
|
||||
const pidPath = join(userDataDir, 'daemon.pid')
|
||||
const launchNonce = randomUUID()
|
||||
const protocolVersion = readProtocolVersion()
|
||||
|
||||
log(`forking ${entryPath} under plain Node (${process.execPath})`)
|
||||
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
})
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
'daemon-boot-smoke'
|
||||
],
|
||||
{
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
}
|
||||
)
|
||||
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
@@ -185,6 +215,25 @@ async function main() {
|
||||
})
|
||||
})
|
||||
log('daemon signaled ready')
|
||||
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
|
||||
if (
|
||||
pidRecord.pid !== child.pid ||
|
||||
pidRecord.launchNonce !== launchNonce ||
|
||||
pidRecord.entryPath !== entryPath ||
|
||||
pidRecord.appVersion !== 'daemon-boot-smoke'
|
||||
) {
|
||||
throw new Error('daemon readiness did not publish the expected PID ownership record')
|
||||
}
|
||||
log('PID ownership record matches the ready daemon')
|
||||
if (!existsSync(socketPath)) {
|
||||
throw new Error('daemon did not publish its endpoint at the canonical socket path')
|
||||
}
|
||||
log('endpoint published at the canonical socket path')
|
||||
// Production releases startup-only handles after ready; they can pin the child on Windows.
|
||||
// Diagnostics past this point therefore carry the tail captured up to readiness only.
|
||||
child.stderr?.destroy()
|
||||
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
|
||||
child.disconnect()
|
||||
|
||||
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
|
||||
if (ptyHealthy) {
|
||||
@@ -193,18 +242,39 @@ async function main() {
|
||||
|
||||
await new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
|
||||
}, SHUTDOWN_TIMEOUT_MS)
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
log(`daemon exited after signal (code=${code}, signal=${signal})`)
|
||||
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
|
||||
resolveExit()
|
||||
})
|
||||
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
|
||||
// Windows has no POSIX signal delivery, so Node maps this to process
|
||||
// termination. Either way the hard assertion is "it stops, no hang".
|
||||
child.kill('SIGTERM')
|
||||
void runDaemonRpc(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
{
|
||||
id: 'shutdown-1',
|
||||
type: 'shutdown',
|
||||
payload: { killSessions: false }
|
||||
},
|
||||
SHUTDOWN_TIMEOUT_MS
|
||||
).catch((error) => {
|
||||
clearTimeout(timer)
|
||||
rejectExit(error)
|
||||
})
|
||||
})
|
||||
if (existsSync(pidPath)) {
|
||||
throw new Error('daemon left its PID ownership record behind after shutdown')
|
||||
}
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
throw new Error('daemon left its endpoint socket behind after shutdown')
|
||||
}
|
||||
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
|
||||
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
|
||||
}
|
||||
|
||||
log('PASS: daemon booted, served, and shut down under plain Node')
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
|
||||
*
|
||||
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
|
||||
* alive used to delete the *replacement's* socket when it finally exited, because libuv
|
||||
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
|
||||
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
|
||||
* run it, and that a user cannot fix by restarting the app.
|
||||
*
|
||||
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
|
||||
*
|
||||
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
||||
*/
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
const EXIT_TIMEOUT_MS = 15_000
|
||||
|
||||
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
|
||||
|
||||
function bootDaemon(tag, dir, socketPath) {
|
||||
const tokenPath = join(dir, `${tag}.token`)
|
||||
const pidPath = join(dir, `${tag}.pid`)
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
randomUUID(),
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
'endpoint-handover-smoke'
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
|
||||
}
|
||||
)
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
return new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(
|
||||
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
|
||||
READY_TIMEOUT_MS
|
||||
)
|
||||
child.on('message', (msg) => {
|
||||
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady({ child, tokenPath, pidPath })
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isReachable(socketPath) {
|
||||
return new Promise((resolveReachable) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
socket.on('connect', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(true)
|
||||
})
|
||||
socket.on('error', () => resolveReachable(false))
|
||||
})
|
||||
}
|
||||
|
||||
function killAndWait(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolveExit()
|
||||
})
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform === 'win32') {
|
||||
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
|
||||
return
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
|
||||
const socketPath = join(dir, 'daemon.sock')
|
||||
let replaced
|
||||
let replacement
|
||||
try {
|
||||
replaced = await bootDaemon('replaced', dir, socketPath)
|
||||
const replacedInode = statSync(socketPath).ino
|
||||
log('daemon A published the endpoint')
|
||||
|
||||
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
|
||||
unlinkSync(socketPath)
|
||||
replacement = await bootDaemon('replacement', dir, socketPath)
|
||||
const replacementInode = statSync(socketPath).ino
|
||||
if (replacedInode === replacementInode) {
|
||||
throw new Error('daemon B did not publish a distinct endpoint')
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B is not reachable through the canonical endpoint')
|
||||
}
|
||||
log('daemon B took over the endpoint and is reachable')
|
||||
|
||||
// A exits long after losing the endpoint. This is the step that used to break B.
|
||||
await killAndWait(replaced.child)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
|
||||
throw new Error("daemon A's late exit deleted daemon B's endpoint")
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B became unreachable after daemon A exited')
|
||||
}
|
||||
if (!existsSync(replacement.pidPath)) {
|
||||
throw new Error("daemon A's late exit removed daemon B's ownership record")
|
||||
}
|
||||
if (existsSync(replaced.pidPath)) {
|
||||
throw new Error('daemon A left its own ownership record behind')
|
||||
}
|
||||
|
||||
log('PASS: the endpoint owner and the session host stayed the same daemon')
|
||||
} finally {
|
||||
for (const daemon of [replaced, replacement]) {
|
||||
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
||||
try {
|
||||
daemon.child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('documents per-invocation model and effort for supervised workers', () => {
|
||||
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
|
||||
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
|
||||
@@ -10,7 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
|
||||
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { loadHostCatalog } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
|
||||
@@ -95,9 +95,12 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
async function getNavigationTarget(data: unknown) {
|
||||
const hosts = await loadHosts().catch(() => null)
|
||||
const hosts = await loadHostCatalog().catch(() => null)
|
||||
return getNotificationNavigationTarget(data, {
|
||||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
|
||||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
|
||||
credentialStatusByHostId: hosts
|
||||
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+66
-20
@@ -15,7 +15,8 @@ import {
|
||||
UsageBar
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { loadHostCatalog } from '../src/transport/host-store'
|
||||
import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection'
|
||||
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
|
||||
@@ -26,13 +27,17 @@ import { createHostConnectRefetchGate } from '../src/transport/host-connect-refe
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import {
|
||||
resolveHomeHostConnectionState,
|
||||
selectHomeAutoConnectHostIds
|
||||
} from '../src/transport/home-host-auto-connect'
|
||||
import { classifyConnection } from '../src/transport/connection-health'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { MobileHostCard } from '../src/components/MobileHostCard'
|
||||
@@ -224,9 +229,9 @@ export default function HomeScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
|
||||
@@ -242,10 +247,15 @@ export default function HomeScreen() {
|
||||
const onboardingOptInCheckedRef = useRef(false)
|
||||
|
||||
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
|
||||
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
|
||||
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
|
||||
const allClients = useAllHostClients(hostIds)
|
||||
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
|
||||
const allClients = useAllHostClients(hostIds, {
|
||||
autoConnectHostIds,
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
const hostPaths = useMemo(
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
[allClients]
|
||||
@@ -308,12 +318,12 @@ export default function HomeScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHosts().then(async (h) => {
|
||||
void loadHostCatalog().then(async (catalog) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setHosts(h)
|
||||
if (h.length === 0 || onboardingOptInCheckedRef.current) {
|
||||
setHostCatalog(catalog)
|
||||
if (catalog.length === 0 || onboardingOptInCheckedRef.current) {
|
||||
return
|
||||
}
|
||||
onboardingOptInCheckedRef.current = true
|
||||
@@ -351,6 +361,10 @@ export default function HomeScreen() {
|
||||
() => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
|
||||
[hosts]
|
||||
)
|
||||
const sortedHostCatalog = useMemo(
|
||||
() => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected),
|
||||
[hostCatalog]
|
||||
)
|
||||
|
||||
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
|
||||
useEffect(() => {
|
||||
@@ -389,17 +403,24 @@ export default function HomeScreen() {
|
||||
}
|
||||
}
|
||||
// Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
|
||||
for (const host of hosts) {
|
||||
for (const host of hostCatalog) {
|
||||
if (liveIds.has(host.id)) {
|
||||
continue
|
||||
}
|
||||
if (!host.publicKeyB64 || !host.deviceToken) {
|
||||
if (host.credentialStatus === 'missing') {
|
||||
if (next[host.id] !== 'auth-failed') {
|
||||
next[host.id] = 'auth-failed'
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (host.credentialStatus === 'temporarily-unavailable') {
|
||||
if (next[host.id] !== 'disconnected') {
|
||||
next[host.id] = 'disconnected'
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
const prevState = next[host.id]
|
||||
if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
|
||||
next[host.id] = 'disconnected'
|
||||
@@ -408,14 +429,14 @@ export default function HomeScreen() {
|
||||
}
|
||||
// Drop entries for hosts we no longer track at all.
|
||||
for (const id of Object.keys(next)) {
|
||||
if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) {
|
||||
if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) {
|
||||
delete next[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [allClients, hosts])
|
||||
}, [allClients, hostCatalog])
|
||||
|
||||
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
|
||||
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
|
||||
@@ -629,7 +650,7 @@ export default function HomeScreen() {
|
||||
try {
|
||||
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
|
||||
setConfirmRemove(null)
|
||||
setHosts(await loadHosts())
|
||||
setHostCatalog(await loadHostCatalog())
|
||||
} catch {
|
||||
// Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
|
||||
setConfirmRemove(hostToRemove)
|
||||
@@ -655,7 +676,7 @@ export default function HomeScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
{hostCatalog.length === 0 ? (
|
||||
/* ─── Empty state: onboarding ─── */
|
||||
<View
|
||||
style={[
|
||||
@@ -694,7 +715,7 @@ export default function HomeScreen() {
|
||||
) : (
|
||||
/* ─── Populated state ─── */
|
||||
<FlatList
|
||||
data={sortedHosts}
|
||||
data={sortedHostCatalog}
|
||||
keyExtractor={(h) => h.id}
|
||||
// Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
|
||||
contentContainerStyle={[
|
||||
@@ -732,7 +753,11 @@ export default function HomeScreen() {
|
||||
}
|
||||
ItemSeparatorComponent={CardGap}
|
||||
renderItem={({ item }) => {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const state = resolveHomeHostConnectionState(
|
||||
item.id,
|
||||
hostStates[item.id],
|
||||
autoConnectHostIds
|
||||
)
|
||||
const attempts = hostAttempts[item.id] ?? 0
|
||||
const lastConnectedAt = hostLastConnected[item.id] ?? null
|
||||
const verdict = classifyConnection({
|
||||
@@ -744,14 +769,29 @@ export default function HomeScreen() {
|
||||
return (
|
||||
<MobileHostCard
|
||||
host={item}
|
||||
credentialStatus={item.credentialStatus}
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeInfo={worktreeInfo[item.id]}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onPress={() => {
|
||||
if (item.credentialStatus === 'missing') {
|
||||
router.push('/pair-scan')
|
||||
} else if (item.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(setHostCatalog)
|
||||
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
|
||||
} else {
|
||||
router.push(`/h/${item.id}`)
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setActionTarget(item)
|
||||
if (item.profile) {
|
||||
setActionTarget(item.profile)
|
||||
} else {
|
||||
setConfirmRemove(item)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -920,7 +960,13 @@ export default function HomeScreen() {
|
||||
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
|
||||
actions={getHostListActionSheetActions({
|
||||
host: actionTarget,
|
||||
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
|
||||
state: actionTarget
|
||||
? resolveHomeHostConnectionState(
|
||||
actionTarget.id,
|
||||
hostStates[actionTarget.id],
|
||||
autoConnectHostIds
|
||||
)
|
||||
: 'disconnected',
|
||||
hasEverConnected: actionTarget
|
||||
? (hostLastConnected[actionTarget.id] ?? null) != null
|
||||
: false,
|
||||
@@ -928,7 +974,7 @@ export default function HomeScreen() {
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: closeHostClient,
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: setConfirmRemove
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
|
||||
@@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() {
|
||||
void loadHosts().then(setHosts)
|
||||
}, [])
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const hostClients = useAllHostClients(hostIds)
|
||||
const { clients: hostClients } = useFocusedSettingsHostClients(hostIds)
|
||||
const hostClientsById = useMemo(
|
||||
() => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
|
||||
[hostClients]
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
View
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { BottomDrawer } from '../src/components/BottomDrawer'
|
||||
import { VoiceModelList } from '../src/components/VoiceModelList'
|
||||
@@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
void loadHosts().then(setHosts)
|
||||
}, [])
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const hostClients = useAllHostClients(hostIds)
|
||||
const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds)
|
||||
// Voice dictation runs on the paired desktop, so pick the first connected host.
|
||||
const client: RpcClient | null = useMemo(
|
||||
() => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
|
||||
@@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
|
||||
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
|
||||
const [routeFocused, setRouteFocused] = useState(false)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setRouteFocused(true)
|
||||
return () => setRouteFocused(false)
|
||||
}, [])
|
||||
)
|
||||
|
||||
const refresh = useCallback(async (): Promise<boolean | undefined> => {
|
||||
if (!client) {
|
||||
return false
|
||||
|
||||
@@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from '../transport/types'
|
||||
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
|
||||
import {
|
||||
markHomeWorktreeCatalogUnavailable,
|
||||
type HostWorktreeInfo
|
||||
@@ -54,6 +54,7 @@ describe('MobileHostCard', () => {
|
||||
state?: ConnectionState
|
||||
verdict?: ConnectionVerdict
|
||||
path?: MobileConnectionPath
|
||||
credentialStatus?: HostCredentialStatus
|
||||
}
|
||||
): Promise<string[]> {
|
||||
await act(async () => {
|
||||
@@ -63,6 +64,7 @@ describe('MobileHostCard', () => {
|
||||
state: overrides?.state ?? 'connected',
|
||||
verdict: overrides?.verdict ?? verdict,
|
||||
path: overrides?.path ?? 'lan',
|
||||
credentialStatus: overrides?.credentialStatus,
|
||||
worktreeInfo,
|
||||
onPress: () => {},
|
||||
onLongPress: () => {}
|
||||
@@ -139,4 +141,28 @@ describe('MobileHostCard', () => {
|
||||
expect(lines).not.toContain('0 worktrees')
|
||||
expect(lines).not.toContain('Worktree list unavailable')
|
||||
})
|
||||
|
||||
it('offers re-pairing when the credential is missing', async () => {
|
||||
const lines = await renderCard(loaded, {
|
||||
state: 'connected',
|
||||
verdict: { kind: 'auth-failed', label: 'Pairing invalid' },
|
||||
credentialStatus: 'missing'
|
||||
})
|
||||
|
||||
expect(lines).toContain('Pairing invalid')
|
||||
expect(lines).toContain('Tap to re-pair with your desktop')
|
||||
expect(lines).not.toContain('12 worktrees · 2 active')
|
||||
})
|
||||
|
||||
it('offers a retry without declaring a transient read failure invalid', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'disconnected',
|
||||
verdict: { kind: 'normal', label: 'Disconnected' },
|
||||
credentialStatus: 'temporarily-unavailable'
|
||||
})
|
||||
|
||||
expect(lines).toContain('Pairing temporarily unavailable')
|
||||
expect(lines).toContain('Unlock your phone, then tap to retry')
|
||||
expect(lines).not.toContain('Pairing invalid')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import { verdictDisplayLabel } from '../transport/connection-health'
|
||||
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from '../transport/types'
|
||||
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
import { StatusDot } from './StatusDot'
|
||||
|
||||
export function MobileHostCard(props: {
|
||||
host: HostProfile
|
||||
host: HostProfile | HostCatalogEntry
|
||||
credentialStatus?: HostCatalogEntry['credentialStatus']
|
||||
state: ConnectionState
|
||||
verdict: ConnectionVerdict
|
||||
path: MobileConnectionPath
|
||||
@@ -20,13 +21,26 @@ export function MobileHostCard(props: {
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
}) {
|
||||
const connected = props.state === 'connected'
|
||||
const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable'
|
||||
const credentialMissing = props.credentialStatus === 'missing'
|
||||
const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing
|
||||
// Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the
|
||||
// path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is
|
||||
// named — 'lan' doubles as the unknown-path default, so it would be a guess before connect.
|
||||
const dialingPath =
|
||||
['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay'
|
||||
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
|
||||
const isError =
|
||||
credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
|
||||
const statusLabel = credentialMissing
|
||||
? 'Pairing invalid'
|
||||
: credentialUnavailable
|
||||
? 'Pairing temporarily unavailable'
|
||||
: verdictDisplayLabel(props.verdict)
|
||||
const statusVerdict: ConnectionVerdict = credentialMissing
|
||||
? { kind: 'auth-failed', label: statusLabel }
|
||||
: credentialUnavailable
|
||||
? { kind: 'warning', label: statusLabel }
|
||||
: props.verdict
|
||||
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
|
||||
return (
|
||||
<Pressable
|
||||
@@ -46,10 +60,19 @@ export function MobileHostCard(props: {
|
||||
{props.host.name}
|
||||
</Text>
|
||||
<View style={styles.meta}>
|
||||
<StatusDot state={props.state} verdict={props.verdict} />
|
||||
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
|
||||
{verdictDisplayLabel(props.verdict)}
|
||||
{connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
|
||||
<StatusDot state={props.state} verdict={statusVerdict} />
|
||||
<Text
|
||||
style={[
|
||||
styles.metaText,
|
||||
isError && { color: colors.statusRed },
|
||||
credentialUnavailable && { color: colors.statusAmber }
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{statusLabel}
|
||||
{!credentialMissing && !credentialUnavailable && (connected || dialingPath)
|
||||
? ` · ${mobileConnectionPathLabel(props.path)}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
{connected && worktreeSummary ? (
|
||||
@@ -62,6 +85,13 @@ export function MobileHostCard(props: {
|
||||
Update desktop Orca and sign in to connect from anywhere
|
||||
</Text>
|
||||
) : null}
|
||||
{credentialMissing || credentialUnavailable ? (
|
||||
<Text style={styles.discoveryHint} numberOfLines={2}>
|
||||
{credentialMissing
|
||||
? 'Tap to re-pair with your desktop'
|
||||
: 'Unlock your phone, then tap to retry'}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing'
|
||||
import {
|
||||
buildLocalNotificationData,
|
||||
getNotificationNavigationTarget,
|
||||
notificationCredentialRecoveryRoute
|
||||
} from './notification-routing'
|
||||
|
||||
describe('notification routing', () => {
|
||||
it('includes the host id in locally scheduled notification data', () => {
|
||||
@@ -55,4 +59,32 @@ describe('notification routing', () => {
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', 're-pair'],
|
||||
['temporarily-unavailable', 'retry']
|
||||
] as const)('routes %s host credentials to %s recovery', (status, recovery) => {
|
||||
const target = getNotificationNavigationTarget(
|
||||
{ hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' },
|
||||
{
|
||||
knownHostIds: new Set(['host-1']),
|
||||
credentialStatusByHostId: new Map([['host-1', status]])
|
||||
}
|
||||
)
|
||||
|
||||
expect(target).toMatchObject({ hostId: 'host-1', credentialRecovery: recovery })
|
||||
expect(notificationCredentialRecoveryRoute(target!)).toBe(
|
||||
status === 'missing' ? '/pair-scan' : '/'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ready hosts on the requested notification destination', () => {
|
||||
const target = getNotificationNavigationTarget(
|
||||
{ hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' },
|
||||
{ credentialStatusByHostId: new Map([['host-1', 'ready']]) }
|
||||
)
|
||||
|
||||
expect(target?.sessionTarget).not.toBeNull()
|
||||
expect(notificationCredentialRecoveryRoute(target!)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
|
||||
import { mobileSessionRouteTarget } from '../session/mobile-session-route'
|
||||
import type { HostCredentialStatus } from '../transport/types'
|
||||
|
||||
export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test'
|
||||
|
||||
@@ -18,6 +19,7 @@ export type LocalNotificationData = {
|
||||
|
||||
export type NotificationNavigationOptions = {
|
||||
knownHostIds?: ReadonlySet<string>
|
||||
credentialStatusByHostId?: ReadonlyMap<string, HostCredentialStatus>
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
@@ -46,8 +48,18 @@ export function buildLocalNotificationData(
|
||||
export type NotificationNavigationTarget = Readonly<{
|
||||
hostId: string
|
||||
sessionTarget: HostStackRouteTarget | null
|
||||
credentialRecovery?: 'retry' | 're-pair'
|
||||
}>
|
||||
|
||||
export function notificationCredentialRecoveryRoute(
|
||||
target: NotificationNavigationTarget
|
||||
): '/' | '/pair-scan' | null {
|
||||
if (target.credentialRecovery === 're-pair') {
|
||||
return '/pair-scan'
|
||||
}
|
||||
return target.credentialRecovery === 'retry' ? '/' : null
|
||||
}
|
||||
|
||||
export function getNotificationNavigationTarget(
|
||||
data: unknown,
|
||||
options: NotificationNavigationOptions = {}
|
||||
@@ -66,8 +78,14 @@ export function getNotificationNavigationTarget(
|
||||
}
|
||||
|
||||
const worktreeId = readNonEmptyString(record.worktreeId)
|
||||
const credentialStatus = options.credentialStatusByHostId?.get(hostId)
|
||||
return {
|
||||
hostId,
|
||||
sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null
|
||||
sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null,
|
||||
...(credentialStatus === 'missing'
|
||||
? { credentialRecovery: 're-pair' as const }
|
||||
: credentialStatus === 'temporarily-unavailable'
|
||||
? { credentialRecovery: 'retry' as const }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useCallback } from 'react'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { hostStackHostRoute } from '../navigation/host-stack-navigation'
|
||||
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
|
||||
import type { NotificationNavigationTarget } from './notification-routing'
|
||||
import {
|
||||
notificationCredentialRecoveryRoute,
|
||||
type NotificationNavigationTarget
|
||||
} from './notification-routing'
|
||||
|
||||
export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void {
|
||||
const openHostStackRoute = useOpenHostStackRoute()
|
||||
@@ -10,6 +13,11 @@ export function useOpenNotificationRoute(): (target: NotificationNavigationTarge
|
||||
|
||||
return useCallback(
|
||||
(target) => {
|
||||
const recoveryRoute = notificationCredentialRecoveryRoute(target)
|
||||
if (recoveryRoute) {
|
||||
router.push(recoveryRoute)
|
||||
return
|
||||
}
|
||||
if (target.sessionTarget) {
|
||||
openHostStackRoute(target.hostId, target.sessionTarget)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import type { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import type { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
|
||||
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
|
||||
export type MobileNativeChatController = {
|
||||
/** Whether a tab's effective view is chat (per-tab override, else the default). */
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
toggleTabChatView: (tabId: string) => void
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
/** Resolved agent for the active chat tab (names the empty-state copy). */
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
chatImagePreviewsByMessageId: Record<string, string[]>
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
||||
nativeChatStreamScopeKey: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
/** The pending ask, already null while dismissed (dismissal lives here so it
|
||||
* survives the chat-view subtree unmounting on a view toggle). */
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
/** Stable key for the current ask card (keys the card component). */
|
||||
nativeChatAskKey: string | null
|
||||
/** Hide the current ask until a genuinely different question arrives. */
|
||||
dismissNativeChatAsk: () => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
) => Promise<boolean>
|
||||
handleNativeChatCancelAsk: () => Promise<boolean>
|
||||
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
||||
handleNativeChatStop: () => void
|
||||
nativeChatFilePaths: string[]
|
||||
loadNativeChatFiles: (query: string) => void
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
/** Model/session-option pickers for the composer, or null when the active
|
||||
* agent has no session-option catalog. */
|
||||
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
|
||||
}
|
||||
@@ -1,101 +1,29 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
parseAskFromStatus,
|
||||
type AskAnswerSelection,
|
||||
type AskPrompt
|
||||
} from './mobile-native-chat-ask'
|
||||
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
|
||||
import { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
||||
import {
|
||||
useMobileNativeChatDrafts,
|
||||
type MobileNativeChatPendingMessage
|
||||
} from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
||||
import { useThrottledLatestValue } from './use-throttled-latest-value'
|
||||
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
|
||||
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
|
||||
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
|
||||
|
||||
export type MobileNativeChatController = {
|
||||
/** Whether a tab's effective view is chat (per-tab override, else the default). */
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
toggleTabChatView: (tabId: string) => void
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
/** Resolved agent for the active chat tab (names the empty-state copy). */
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
chatImagePreviewsByMessageId: Record<string, string[]>
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
||||
nativeChatStreamScopeKey: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
/** The pending ask, already null while dismissed (dismissal lives here so it
|
||||
* survives the chat-view subtree unmounting on a view toggle). */
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
/** Stable key for the current ask card (keys the card component). */
|
||||
nativeChatAskKey: string | null
|
||||
/** Hide the current ask until a genuinely different question arrives. */
|
||||
dismissNativeChatAsk: () => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
) => Promise<boolean>
|
||||
handleNativeChatCancelAsk: () => Promise<boolean>
|
||||
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
||||
handleNativeChatStop: () => void
|
||||
nativeChatFilePaths: string[]
|
||||
loadNativeChatFiles: (query: string) => void
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
/** Model/session-option pickers for the composer, or null when the active
|
||||
* agent has no session-option catalog. */
|
||||
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
|
||||
}
|
||||
|
||||
/** Owns mobile native-chat state and teardown outside the already dense session
|
||||
* route. The route remains responsible only for choosing and rendering the view. */
|
||||
export function useMobileNativeChatController(args: {
|
||||
|
||||
@@ -21,6 +21,8 @@ vi.mock('./connection-revival-triggers', () => ({
|
||||
}))
|
||||
|
||||
import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context'
|
||||
import { useAllHostClients } from './use-all-host-clients'
|
||||
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
|
||||
|
||||
type FakeClient = RpcClient & {
|
||||
emitState: (state: ConnectionState) => void
|
||||
@@ -386,3 +388,179 @@ describe('useHostClient', () => {
|
||||
expect(connectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAllHostClients', () => {
|
||||
it('only opens the requested startup subset', async () => {
|
||||
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
|
||||
connectMock.mockReturnValue(makeFakeClient('connected'))
|
||||
loadHostsMock.mockResolvedValue([HOST, host2])
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
useAllHostClients([HOST.id, host2.id], { autoConnectHostIds: [host2.id] })
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledOnce()
|
||||
expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function))
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps startup connection fanout constant for a large saved-host list', async () => {
|
||||
const hosts = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
...HOST,
|
||||
id: `host-${index}`,
|
||||
name: `Host ${index}`,
|
||||
lastConnected: index
|
||||
}))
|
||||
const hostIds = hosts.map((host) => host.id)
|
||||
const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts)
|
||||
connectMock.mockReturnValue(makeFakeClient('connected'))
|
||||
loadHostsMock.mockResolvedValue(hosts)
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
useAllHostClients(hostIds, { autoConnectHostIds })
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledTimes(3)
|
||||
expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([
|
||||
'host-999',
|
||||
'host-998',
|
||||
'host-997'
|
||||
])
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
it('closes a demoted Home client when the recent-host set rotates', async () => {
|
||||
const hosts = [
|
||||
{ ...HOST, id: 'host-a', lastConnected: 4 },
|
||||
{ ...HOST, id: 'host-b', lastConnected: 3 },
|
||||
{ ...HOST, id: 'host-c', lastConnected: 2 },
|
||||
{ ...HOST, id: 'host-d', lastConnected: 1 }
|
||||
]
|
||||
const clients = new Map<string, FakeClient>()
|
||||
connectMock.mockImplementation((profile: typeof HOST) => {
|
||||
const client = makeFakeClient('connected')
|
||||
clients.set(profile.id, client)
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(hosts)
|
||||
|
||||
let activeHostIds: string[] = []
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe({ profiles }: { profiles: typeof hosts }): null {
|
||||
const hostIds = profiles.map((host) => host.id)
|
||||
activeHostIds = useAllHostClients(hostIds, {
|
||||
autoConnectHostIds: selectHomeAutoConnectHostIds(profiles),
|
||||
closeUnusedOnRelease: true
|
||||
}).map(({ hostId }) => hostId)
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts }))
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c'])
|
||||
|
||||
const rotatedHosts = hosts.map((host) =>
|
||||
host.id === 'host-d' ? { ...host, lastConnected: 5 } : host
|
||||
)
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts }))
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(connectMock).toHaveBeenCalledTimes(4)
|
||||
expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d'])
|
||||
expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled()
|
||||
expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled()
|
||||
expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce()
|
||||
expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
it('retains connect-all behavior when no startup subset is provided', async () => {
|
||||
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
|
||||
connectMock.mockReturnValue(makeFakeClient('connected'))
|
||||
loadHostsMock.mockResolvedValue([HOST, host2])
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
useAllHostClients([HOST.id, host2.id])
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
it('allows an excluded host to connect manually', async () => {
|
||||
connectMock.mockReturnValue(makeFakeClient('connected'))
|
||||
loadHostsMock.mockResolvedValue([HOST])
|
||||
|
||||
let reconnect: ((hostId: string) => Promise<void>) | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
useAllHostClients([HOST.id], { autoConnectHostIds: [] })
|
||||
reconnect = useForceReconnect()
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
expect(connectMock).not.toHaveBeenCalled()
|
||||
if (!reconnect) {
|
||||
throw new Error('reconnect harness did not initialize')
|
||||
}
|
||||
await act(async () => {
|
||||
await reconnect?.(HOST.id)
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,9 +14,20 @@ import type { RpcClient } from './rpc-client'
|
||||
import { connectionLogStore } from './connection-log-buffer'
|
||||
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
|
||||
import { HostClientOpenRegistry } from './host-client-open-registry'
|
||||
import { decrementPendingAcquisition } from './host-client-acquisition-count'
|
||||
import {
|
||||
clientActivePath,
|
||||
listHostClients,
|
||||
notifyAllHostListeners,
|
||||
notifyHostStateListeners,
|
||||
primeHostProfiles,
|
||||
subscribeAllHostListener,
|
||||
subscribeHostStateListener,
|
||||
type CloseEntryOptions
|
||||
} from './host-client-context-state'
|
||||
import { loadHosts } from './host-store'
|
||||
import { openHostLogicalClient } from './host-logical-client'
|
||||
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
type StoreEntry = {
|
||||
@@ -29,6 +40,8 @@ type StoreEntry = {
|
||||
export type RpcClientContextValue = {
|
||||
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
|
||||
release: (hostId: string) => void
|
||||
releaseAndCloseIfUnused: (hostId: string) => void
|
||||
closeIfUnused: (hostId: string) => void
|
||||
forceReconnect: (hostId: string) => Promise<void>
|
||||
closeHost: (hostId: string) => void
|
||||
getState: (hostId: string) => ConnectionState
|
||||
@@ -55,30 +68,27 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
|
||||
const pendingOpensRef = useRef(new HostClientOpenRegistry())
|
||||
const pendingAcquisitionsRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
|
||||
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
|
||||
|
||||
function notifyHostState(hostId: string, state: ConnectionState) {
|
||||
const set = stateListenersRef.current.get(hostId)
|
||||
if (!set) {
|
||||
return
|
||||
}
|
||||
for (const listener of set) {
|
||||
listener(state)
|
||||
}
|
||||
}
|
||||
const notifyHostState = (hostId: string, state: ConnectionState) =>
|
||||
notifyHostStateListeners(stateListenersRef.current, hostId, state)
|
||||
const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current)
|
||||
|
||||
function notifyAllHosts() {
|
||||
for (const listener of allHostsListenersRef.current) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
const closeEntry = useCallback((hostId: string) => {
|
||||
pendingOpensRef.current.cancel(hostId)
|
||||
primedHostsRef.current.delete(hostId)
|
||||
const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
|
||||
pendingOpensRef.current.cancel(hostId)
|
||||
if (options.preserveAcquisitions && acquisitionCount > 0) {
|
||||
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
|
||||
} else {
|
||||
pendingAcquisitionsRef.current.delete(hostId)
|
||||
}
|
||||
if (options.forgetPrimedHost) {
|
||||
primedHostsRef.current.delete(hostId)
|
||||
}
|
||||
entry?.unsubState()
|
||||
storeRef.current.delete(hostId)
|
||||
entry?.client.close()
|
||||
@@ -86,6 +96,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
notifyAllHosts()
|
||||
}, [])
|
||||
|
||||
const closeHost = useCallback(
|
||||
(hostId: string) => {
|
||||
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true })
|
||||
},
|
||||
[closeEntry]
|
||||
)
|
||||
|
||||
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
|
||||
const existing = pendingOpensRef.current.getActivePromise(hostId)
|
||||
if (existing) {
|
||||
@@ -151,9 +168,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const entry: StoreEntry = {
|
||||
client,
|
||||
state: client.getState(),
|
||||
refCount: 0,
|
||||
refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0,
|
||||
unsubState
|
||||
}
|
||||
pendingAcquisitionsRef.current.delete(hostId)
|
||||
storeRef.current.set(hostId, entry)
|
||||
notifyHostState(hostId, entry.state)
|
||||
notifyAllHosts()
|
||||
@@ -175,50 +193,83 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
existing.refCount += 1
|
||||
return existing.client
|
||||
}
|
||||
pendingAcquisitionsRef.current.set(
|
||||
hostId,
|
||||
(pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1
|
||||
)
|
||||
// Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
|
||||
void openEntry(hostId).then((entry) => {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
entry.refCount += 1
|
||||
})
|
||||
void openEntry(hostId)
|
||||
return null
|
||||
},
|
||||
[openEntry]
|
||||
)
|
||||
|
||||
const primeHosts = useCallback((hosts: HostProfile[]) => {
|
||||
for (const host of hosts) {
|
||||
primedHostsRef.current.set(host.id, host)
|
||||
}
|
||||
}, [])
|
||||
const primeHosts = useCallback(
|
||||
(hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts),
|
||||
[]
|
||||
)
|
||||
|
||||
// Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
|
||||
const release = useCallback((hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
if (!entry) {
|
||||
if (entry) {
|
||||
entry.refCount = Math.max(0, entry.refCount - 1)
|
||||
return
|
||||
}
|
||||
entry.refCount = Math.max(0, entry.refCount - 1)
|
||||
decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId)
|
||||
}, [])
|
||||
|
||||
const releaseAndCloseIfUnused = useCallback(
|
||||
(hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
if (entry) {
|
||||
entry.refCount = Math.max(0, entry.refCount - 1)
|
||||
if (entry.refCount === 0) {
|
||||
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) {
|
||||
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
|
||||
}
|
||||
},
|
||||
[closeEntry]
|
||||
)
|
||||
|
||||
const closeIfUnused = useCallback(
|
||||
(hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
const pendingCount = pendingAcquisitionsRef.current.get(hostId)
|
||||
const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null
|
||||
if (!entry && pendingCount === undefined && !hasPendingOpen) {
|
||||
return
|
||||
}
|
||||
if ((entry?.refCount ?? pendingCount ?? 0) === 0) {
|
||||
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
|
||||
}
|
||||
},
|
||||
[closeEntry]
|
||||
)
|
||||
|
||||
const forceReconnect = useCallback(
|
||||
async (hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
// Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count.
|
||||
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
|
||||
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
|
||||
// Why: ownership survives explicit close/re-pair while observers never become synthetic owners.
|
||||
const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
|
||||
if (entry) {
|
||||
entry.unsubState()
|
||||
entry.client.close()
|
||||
storeRef.current.delete(hostId)
|
||||
}
|
||||
if (savedRefCount > 0) {
|
||||
pendingAcquisitionsRef.current.set(
|
||||
hostId,
|
||||
Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0)
|
||||
)
|
||||
}
|
||||
// Why: Retry must read amber for the whole reopen, not grey-then-amber.
|
||||
notifyHostState(hostId, 'connecting')
|
||||
const fresh = await openEntry(hostId)
|
||||
if (fresh) {
|
||||
fresh.refCount = savedRefCount
|
||||
}
|
||||
await openEntry(hostId)
|
||||
},
|
||||
[openEntry]
|
||||
)
|
||||
@@ -252,41 +303,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const subscribeHostState = useCallback(
|
||||
(hostId: string, listener: (state: ConnectionState) => void) => {
|
||||
let set = stateListenersRef.current.get(hostId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
stateListenersRef.current.set(hostId, set)
|
||||
}
|
||||
set.add(listener)
|
||||
return () => {
|
||||
const s = stateListenersRef.current.get(hostId)
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
s.delete(listener)
|
||||
if (s.size === 0) {
|
||||
stateListenersRef.current.delete(hostId)
|
||||
}
|
||||
}
|
||||
},
|
||||
(hostId: string, listener: (state: ConnectionState) => void) =>
|
||||
subscribeHostStateListener(stateListenersRef.current, hostId, listener),
|
||||
[]
|
||||
)
|
||||
|
||||
const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => {
|
||||
const out: Array<{ hostId: string; client: RpcClient }> = []
|
||||
for (const [hostId, entry] of storeRef.current) {
|
||||
out.push({ hostId, client: entry.client })
|
||||
}
|
||||
return out
|
||||
}, [])
|
||||
const getAllClients = useCallback(() => listHostClients(storeRef.current), [])
|
||||
|
||||
const subscribeAllHosts = useCallback((listener: () => void) => {
|
||||
allHostsListenersRef.current.add(listener)
|
||||
return () => {
|
||||
allHostsListenersRef.current.delete(listener)
|
||||
}
|
||||
}, [])
|
||||
const subscribeAllHosts = useCallback(
|
||||
(listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener),
|
||||
[]
|
||||
)
|
||||
|
||||
// Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -294,8 +321,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const store = storeRef.current
|
||||
return () => {
|
||||
pendingOpensRef.current.cancelAll()
|
||||
pendingAcquisitionsRef.current.clear()
|
||||
for (const [hostId] of store) {
|
||||
closeEntry(hostId)
|
||||
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
@@ -313,8 +341,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
acquire,
|
||||
release,
|
||||
releaseAndCloseIfUnused,
|
||||
closeIfUnused,
|
||||
forceReconnect,
|
||||
closeHost: closeEntry,
|
||||
closeHost,
|
||||
getState,
|
||||
getKnownState,
|
||||
getReconnectAttempt,
|
||||
@@ -328,8 +358,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
[
|
||||
acquire,
|
||||
release,
|
||||
releaseAndCloseIfUnused,
|
||||
closeIfUnused,
|
||||
forceReconnect,
|
||||
closeEntry,
|
||||
closeHost,
|
||||
getState,
|
||||
getKnownState,
|
||||
getReconnectAttempt,
|
||||
@@ -437,13 +469,3 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.primeHosts
|
||||
}
|
||||
|
||||
function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
|
||||
const logical = client as Partial<StableLogicalRpcClient> | undefined
|
||||
if (typeof logical?.getActivePath !== 'function') {
|
||||
return 'lan'
|
||||
}
|
||||
// Why: mid-migration the active path still names the session being replaced; the
|
||||
// pending one is what the user is actually waiting on (F5).
|
||||
return logical.getPendingPath?.() ?? logical.getActivePath()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HostProfile } from './types'
|
||||
import {
|
||||
HOME_AUTO_CONNECT_LIMIT,
|
||||
resolveHomeHostConnectionState,
|
||||
selectHomeAutoConnectHostIds
|
||||
} from './home-host-auto-connect'
|
||||
|
||||
function host(id: string, lastConnected: number, credentials = true): HostProfile {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
endpoint: `ws://${id}`,
|
||||
deviceToken: credentials ? `token-${id}` : '',
|
||||
publicKeyB64: credentials ? `key-${id}` : '',
|
||||
lastConnected
|
||||
}
|
||||
}
|
||||
|
||||
describe('home host auto-connect', () => {
|
||||
it('limits startup connections to the most recently used credentialed hosts', () => {
|
||||
const hosts = [
|
||||
host('old', 1),
|
||||
host('newest', 5),
|
||||
host('second', 4),
|
||||
host('third', 3),
|
||||
host('fourth', 2),
|
||||
host('missing-credentials', 6, false)
|
||||
]
|
||||
|
||||
expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third'])
|
||||
expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT)
|
||||
})
|
||||
|
||||
it('does not mutate the host card order', () => {
|
||||
const hosts = [host('old', 1), host('new', 2)]
|
||||
|
||||
selectHomeAutoConnectHostIds(hosts)
|
||||
|
||||
expect(hosts.map((item) => item.id)).toEqual(['old', 'new'])
|
||||
})
|
||||
|
||||
it('only presents hosts in the startup subset as connecting before clients open', () => {
|
||||
const autoConnectHostIds = ['recent']
|
||||
|
||||
expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe(
|
||||
'connecting'
|
||||
)
|
||||
expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe(
|
||||
'disconnected'
|
||||
)
|
||||
expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe(
|
||||
'connected'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
export const HOME_AUTO_CONNECT_LIMIT = 3
|
||||
|
||||
export function selectHomeAutoConnectHostIds(
|
||||
hosts: readonly HostProfile[],
|
||||
limit = HOME_AUTO_CONNECT_LIMIT
|
||||
): string[] {
|
||||
return [...hosts]
|
||||
.filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0)
|
||||
.sort(
|
||||
(left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id)
|
||||
)
|
||||
.slice(0, Math.max(0, limit))
|
||||
.map((host) => host.id)
|
||||
}
|
||||
|
||||
export function resolveHomeHostConnectionState(
|
||||
hostId: string,
|
||||
state: ConnectionState | undefined,
|
||||
autoConnectHostIds: readonly string[]
|
||||
): ConnectionState {
|
||||
return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected')
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
|
||||
import type { HostListSnapshot } from './host-list-load-sharing'
|
||||
import type { HostCatalogEntry, StoredHostProfile } from './types'
|
||||
|
||||
export async function joinHostCatalogCredentials(args: {
|
||||
storedHosts: readonly StoredHostProfile[]
|
||||
overlays: ReadonlyMap<string, MobileRelayHostOverlay>
|
||||
tokenCache: Map<string, string>
|
||||
readToken: (hostId: string) => Promise<string | null>
|
||||
getRevision: () => number
|
||||
}): Promise<HostListSnapshot> {
|
||||
const catalog: HostCatalogEntry[] = []
|
||||
const profiles: HostListSnapshot['profiles'] = []
|
||||
for (const stored of args.storedHosts) {
|
||||
let token = args.tokenCache.get(stored.id)
|
||||
let credentialStatus: HostCatalogEntry['credentialStatus'] = 'ready'
|
||||
if (!token) {
|
||||
const readRevision = args.getRevision()
|
||||
let fetched: string | null
|
||||
try {
|
||||
fetched = await args.readToken(stored.id)
|
||||
} catch {
|
||||
credentialStatus = 'temporarily-unavailable'
|
||||
fetched = null
|
||||
}
|
||||
if (!fetched && credentialStatus === 'ready') {
|
||||
credentialStatus = 'missing'
|
||||
}
|
||||
token = fetched ?? undefined
|
||||
if (token && readRevision === args.getRevision()) {
|
||||
args.tokenCache.set(stored.id, token)
|
||||
}
|
||||
}
|
||||
const overlay = args.overlays.get(stored.id)
|
||||
const base = {
|
||||
...stored,
|
||||
...(overlay
|
||||
? {
|
||||
endpoints: overlay.endpoints,
|
||||
relayHostId: overlay.relayHostId,
|
||||
relay: overlay.relay
|
||||
}
|
||||
: {})
|
||||
}
|
||||
const profile = token ? { ...base, deviceToken: token } : null
|
||||
catalog.push({ ...base, credentialStatus, profile })
|
||||
if (profile) {
|
||||
profiles.push(profile)
|
||||
}
|
||||
}
|
||||
return { catalog, profiles }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectConnectableHostProfiles } from './host-catalog-selection'
|
||||
import type { HostCatalogEntry, HostProfile } from './types'
|
||||
|
||||
const profile: HostProfile = {
|
||||
id: 'host-ready',
|
||||
name: 'Ready',
|
||||
endpoint: 'ws://127.0.0.1:1',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key-ready',
|
||||
lastConnected: 0
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
id: string,
|
||||
credentialStatus: 'temporarily-unavailable' | 'missing'
|
||||
): HostCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
endpoint: `ws://127.0.0.1/${id}`,
|
||||
publicKeyB64: `key-${id}`,
|
||||
lastConnected: 0,
|
||||
credentialStatus,
|
||||
profile: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectConnectableHostProfiles', () => {
|
||||
it('excludes unavailable catalog entries from connection priming', () => {
|
||||
expect(
|
||||
selectConnectableHostProfiles([
|
||||
{ ...profile, credentialStatus: 'ready', profile },
|
||||
unavailable('host-locked', 'temporarily-unavailable'),
|
||||
unavailable('host-missing', 'missing')
|
||||
])
|
||||
).toEqual([profile])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { HostCatalogEntry, HostProfile } from './types'
|
||||
|
||||
export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry[]): HostProfile[] {
|
||||
return catalog.flatMap((entry) =>
|
||||
entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : []
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function decrementPendingAcquisition(pending: Map<string, number>, hostId: string): number {
|
||||
const next = Math.max(0, (pending.get(hostId) ?? 0) - 1)
|
||||
if (next === 0) {
|
||||
pending.delete(hostId)
|
||||
} else {
|
||||
pending.set(hostId, next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
export type CloseEntryOptions = {
|
||||
forgetPrimedHost: boolean
|
||||
preserveAcquisitions: boolean
|
||||
}
|
||||
|
||||
export function notifyHostStateListeners(
|
||||
listeners: Map<string, Set<(state: ConnectionState) => void>>,
|
||||
hostId: string,
|
||||
state: ConnectionState
|
||||
): void {
|
||||
for (const listener of listeners.get(hostId) ?? []) {
|
||||
listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyAllHostListeners(listeners: Set<() => void>): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeHostStateListener(
|
||||
listeners: Map<string, Set<(state: ConnectionState) => void>>,
|
||||
hostId: string,
|
||||
listener: (state: ConnectionState) => void
|
||||
): () => void {
|
||||
let hostListeners = listeners.get(hostId)
|
||||
if (!hostListeners) {
|
||||
hostListeners = new Set()
|
||||
listeners.set(hostId, hostListeners)
|
||||
}
|
||||
hostListeners.add(listener)
|
||||
return () => {
|
||||
hostListeners.delete(listener)
|
||||
if (hostListeners.size === 0) {
|
||||
listeners.delete(hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAllHostListener(
|
||||
listeners: Set<() => void>,
|
||||
listener: () => void
|
||||
): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function listHostClients(
|
||||
entries: ReadonlyMap<string, { client: RpcClient }>
|
||||
): { hostId: string; client: RpcClient }[] {
|
||||
return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client }))
|
||||
}
|
||||
|
||||
export function primeHostProfiles(cache: Map<string, HostProfile>, hosts: HostProfile[]): void {
|
||||
for (const host of hosts) {
|
||||
cache.set(host.id, host)
|
||||
}
|
||||
}
|
||||
|
||||
export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
|
||||
const logical = client as Partial<StableLogicalRpcClient> | undefined
|
||||
if (typeof logical?.getActivePath !== 'function') {
|
||||
return 'lan'
|
||||
}
|
||||
// Why: during migration the pending path is what the user is waiting on.
|
||||
return logical.getPendingPath?.() ?? logical.getActivePath()
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
cancelPendingHostCredentialCleanup,
|
||||
loadPendingHostCredentialCleanup,
|
||||
loadPendingHostCredentialCleanupIds,
|
||||
recordHostCredentialCleanupIntent,
|
||||
resetHostCredentialCleanupForTests,
|
||||
retryPendingHostCredentialCleanups,
|
||||
scheduleHostCredentialCleanup,
|
||||
@@ -66,6 +68,12 @@ describe('host credential cleanup', () => {
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('records cleanup intent without starting native deletion', async () => {
|
||||
await recordHostCredentialCleanupIntent('host-1')
|
||||
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
|
||||
})
|
||||
|
||||
it('persists cleanup intent before a rejected delete for manual retry', async () => {
|
||||
const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable'))
|
||||
const listener = vi.fn()
|
||||
@@ -78,6 +86,18 @@ describe('host credential cleanup', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('cancels stale cleanup intent after replacement publication', async () => {
|
||||
const deleteCredential = vi.fn().mockRejectedValue(new Error('superseded'))
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 20)
|
||||
await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce())
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
|
||||
|
||||
await cancelPendingHostCredentialCleanup('host-1')
|
||||
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('records intent before a stalled delete times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
const deleteCredential = vi.fn(() => new Promise<void>(() => undefined))
|
||||
|
||||
@@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
|
||||
|
||||
async function recordCleanupIntent(hostId: string): Promise<boolean> {
|
||||
try {
|
||||
await addPendingId(hostId)
|
||||
await recordHostCredentialCleanupIntent(hostId)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordHostCredentialCleanupIntent(hostId: string): Promise<void> {
|
||||
await addPendingId(hostId)
|
||||
}
|
||||
|
||||
async function confirmNativeCleanup(
|
||||
hostId: string,
|
||||
deleteCredential: DeleteHostCredential,
|
||||
@@ -209,6 +213,11 @@ export function subscribePendingHostCredentialCleanup(listener: () => void): ()
|
||||
return () => pendingListeners.delete(listener)
|
||||
}
|
||||
|
||||
export async function cancelPendingHostCredentialCleanup(hostId: string): Promise<void> {
|
||||
clearUnrecordedPending(hostId)
|
||||
await removePendingId(hostId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cleanup intent, then fire-and-forget the native keychain delete so
|
||||
* removeHost never blocks on SecureStore. If the durable intent write fails,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
let nextRevision = 1
|
||||
const revisionByHostId = new Map<string, number>()
|
||||
|
||||
export function getHostCredentialWriteRevision(hostId: string): number {
|
||||
return revisionByHostId.get(hostId) ?? 0
|
||||
}
|
||||
|
||||
export function markHostCredentialWrite(hostId: string): void {
|
||||
revisionByHostId.set(hostId, nextRevision)
|
||||
nextRevision += 1
|
||||
}
|
||||
|
||||
export function clearHostCredentialWriteRevision(hostId: string): void {
|
||||
revisionByHostId.delete(hostId)
|
||||
}
|
||||
|
||||
/** Test-only: clear session credential generations between cases. */
|
||||
export function resetHostCredentialWriteRevisionsForTests(): void {
|
||||
nextRevision = 1
|
||||
revisionByHostId.clear()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { Platform } from 'react-native'
|
||||
import {
|
||||
deletePairingKeychainItem,
|
||||
readPairingKeychainItem,
|
||||
writePairingKeychainItem
|
||||
} from './pairing-keychain'
|
||||
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
|
||||
const TOKEN_KEY_PREFIX = 'orca.host-token.'
|
||||
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
|
||||
|
||||
function tokenKey(hostId: string): string {
|
||||
return `${TOKEN_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
|
||||
function webTokenKey(hostId: string): string {
|
||||
return `${WEB_TOKEN_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
|
||||
export async function readHostDeviceToken(hostId: string): Promise<string | null> {
|
||||
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
|
||||
if (Platform.OS === 'web') {
|
||||
return AsyncStorage.getItem(webTokenKey(hostId))
|
||||
}
|
||||
return readPairingKeychainItem(tokenKey(hostId))
|
||||
}
|
||||
|
||||
export async function writeHostDeviceToken(hostId: string, token: string): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.setItem(webTokenKey(hostId), token)
|
||||
return
|
||||
}
|
||||
await writePairingKeychainItem(tokenKey(hostId), token)
|
||||
}
|
||||
|
||||
export async function deleteHostDeviceToken(hostId: string): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.removeItem(webTokenKey(hostId))
|
||||
return
|
||||
}
|
||||
await deletePairingKeychainItem(tokenKey(hostId))
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { HostProfile } from './types'
|
||||
import type { HostCatalogEntry, HostProfile } from './types'
|
||||
|
||||
export type HostListSnapshot = {
|
||||
catalog: HostCatalogEntry[]
|
||||
profiles: HostProfile[]
|
||||
}
|
||||
|
||||
// Why: concurrent callers share a slow Keychain pass; durable writes invalidate
|
||||
// it so later loads cannot receive stale snapshots (#8791).
|
||||
let inflight: Promise<HostProfile[]> | null = null
|
||||
let inflight: Promise<HostListSnapshot> | null = null
|
||||
let revision = 0
|
||||
|
||||
export function getHostListLoadRevision(): number {
|
||||
return revision
|
||||
}
|
||||
|
||||
export function shareHostListLoad(load: () => Promise<HostProfile[]>): Promise<HostProfile[]> {
|
||||
export function shareHostListLoad(
|
||||
load: () => Promise<HostListSnapshot>
|
||||
): Promise<HostListSnapshot> {
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
|
||||
export async function loadStoredHostProfiles(): Promise<StoredHostProfile[] | null> {
|
||||
return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY))
|
||||
}
|
||||
|
||||
export async function readStoredHostProfilesForMutation(): Promise<StoredHostProfile[]> {
|
||||
try {
|
||||
const parsed = await loadStoredHostProfiles()
|
||||
if (parsed) {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Normalize storage and payload failures for fail-closed mutations.
|
||||
}
|
||||
throw new Error('host list storage unreadable')
|
||||
}
|
||||
|
||||
export function writeStoredHostProfiles(hosts: readonly StoredHostProfile[]): Promise<void> {
|
||||
return AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
}
|
||||
|
||||
export function toStoredHostProfile(host: HostProfile): StoredHostProfile {
|
||||
const { id, name, endpoint, publicKeyB64, lastConnected } = host
|
||||
return { id, name, endpoint, publicKeyB64, lastConnected }
|
||||
}
|
||||
|
||||
function parseStoredHostProfiles(raw: string | null): StoredHostProfile[] | null {
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return parsed.flatMap((item) => {
|
||||
// Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
return []
|
||||
}
|
||||
const result = StoredHostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const secureStoreMock = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const scheduleCleanupMock = vi.hoisted(() => vi.fn())
|
||||
const cancelCleanupMock = vi.hoisted(() => vi.fn())
|
||||
const recordCleanupIntentMock = vi.hoisted(() => vi.fn())
|
||||
const platformMock = vi.hoisted(() => ({ OS: 'ios' }))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -30,11 +32,14 @@ vi.mock('react-native', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./host-credential-cleanup', () => ({
|
||||
cancelPendingHostCredentialCleanup: (...args: unknown[]) => cancelCleanupMock(...args),
|
||||
recordHostCredentialCleanupIntent: (...args: unknown[]) => recordCleanupIntentMock(...args),
|
||||
scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args),
|
||||
retryPendingHostCredentialCleanups: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
loadHostCatalog,
|
||||
loadHosts,
|
||||
MobileRelayUpgradeHostRemovedError,
|
||||
removeHost,
|
||||
@@ -46,6 +51,7 @@ import {
|
||||
updateLastConnected
|
||||
} from './host-store'
|
||||
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
|
||||
import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
|
||||
const HOSTS_STORAGE_KEY = 'orca:hosts'
|
||||
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
|
||||
@@ -63,6 +69,23 @@ const HOST_TWO = {
|
||||
publicKeyB64: 'key-2',
|
||||
lastConnected: 0
|
||||
}
|
||||
const HOST_ONE_RELAY_BUNDLE = {
|
||||
v: 1 as const,
|
||||
hostId: HOST_ONE.id,
|
||||
deviceToken: 'replacement-token',
|
||||
current: {
|
||||
token: 'A'.repeat(43),
|
||||
hash: 'B'.repeat(43),
|
||||
version: 1,
|
||||
expiresAt: 10_000
|
||||
}
|
||||
}
|
||||
|
||||
function scheduledCleanup(hostId: string): (id: string) => Promise<void> {
|
||||
const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1]
|
||||
expect(cleanup).toBeTypeOf('function')
|
||||
return cleanup as (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
describe('host-store list mutations', () => {
|
||||
let storedHostsRaw: string
|
||||
@@ -75,6 +98,10 @@ describe('host-store list mutations', () => {
|
||||
resetMobileRelayHostOverlayStoreForTests()
|
||||
scheduleCleanupMock.mockReset()
|
||||
scheduleCleanupMock.mockResolvedValue(undefined)
|
||||
cancelCleanupMock.mockReset()
|
||||
cancelCleanupMock.mockResolvedValue(undefined)
|
||||
recordCleanupIntentMock.mockReset()
|
||||
recordCleanupIntentMock.mockResolvedValue(undefined)
|
||||
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
|
||||
storedOverlayRaw = null
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
@@ -93,6 +120,7 @@ describe('host-store list mutations', () => {
|
||||
storedOverlayRaw = raw
|
||||
}
|
||||
})
|
||||
secureStoreMock.setItemAsync.mockResolvedValue(undefined)
|
||||
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
|
||||
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
|
||||
)
|
||||
@@ -114,6 +142,55 @@ describe('host-store list mutations', () => {
|
||||
expect(asyncStorageMock.getItem).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a host visible when its credential read temporarily fails', async () => {
|
||||
secureStoreMock.getItemAsync.mockImplementation(async (key: string) => {
|
||||
if (key.endsWith(HOST_ONE.id)) {
|
||||
throw new Error('keychain locked')
|
||||
}
|
||||
return key.endsWith(HOST_TWO.id) ? 'token-2' : null
|
||||
})
|
||||
|
||||
const catalog = await loadHostCatalog()
|
||||
const profiles = await loadHosts()
|
||||
|
||||
expect(catalog.map(({ id }) => id)).toEqual([HOST_ONE.id, HOST_TWO.id])
|
||||
expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
|
||||
credentialStatus: 'temporarily-unavailable',
|
||||
profile: null
|
||||
})
|
||||
expect(profiles.map(({ id }) => id)).toEqual([HOST_TWO.id])
|
||||
})
|
||||
|
||||
it('keeps orphaned metadata visible when its credential is missing', async () => {
|
||||
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
|
||||
key.endsWith(HOST_TWO.id) ? 'token-2' : null
|
||||
)
|
||||
|
||||
const catalog = await loadHostCatalog()
|
||||
|
||||
expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
|
||||
credentialStatus: 'missing',
|
||||
profile: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps existing metadata when pairing a distinct host', async () => {
|
||||
await saveHost({
|
||||
id: 'host-new',
|
||||
name: 'Host 3',
|
||||
endpoint: 'ws://127.0.0.1:3',
|
||||
publicKeyB64: 'key-new',
|
||||
deviceToken: 'token-new',
|
||||
lastConnected: 0
|
||||
})
|
||||
|
||||
expect(JSON.parse(storedHostsRaw).map(({ id }: { id: string }) => id)).toEqual([
|
||||
HOST_ONE.id,
|
||||
HOST_TWO.id,
|
||||
'host-new'
|
||||
])
|
||||
})
|
||||
|
||||
it('fails closed when durable host identity storage is unreadable', async () => {
|
||||
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
|
||||
@@ -134,9 +211,173 @@ describe('host-store list mutations', () => {
|
||||
})
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }])
|
||||
expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
asyncStorageMock.setItem.mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function))
|
||||
})
|
||||
|
||||
it('does not remove same-key metadata when the replacement credential write fails', async () => {
|
||||
const duplicate = {
|
||||
...HOST_TWO,
|
||||
id: 'host-duplicate',
|
||||
publicKeyB64: HOST_ONE.publicKeyB64
|
||||
}
|
||||
storedHostsRaw = JSON.stringify([HOST_ONE, duplicate])
|
||||
secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed'))
|
||||
|
||||
await expect(
|
||||
saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' })
|
||||
).rejects.toThrow('keychain write failed')
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate])
|
||||
expect(cancelCleanupMock).toHaveBeenCalledWith(duplicate.id)
|
||||
expect(scheduleCleanupMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves a committed replacement token when same-key metadata persistence fails', async () => {
|
||||
const duplicate = {
|
||||
...HOST_TWO,
|
||||
id: 'host-duplicate',
|
||||
publicKeyB64: HOST_ONE.publicKeyB64
|
||||
}
|
||||
storedHostsRaw = JSON.stringify([HOST_ONE, duplicate])
|
||||
await loadHosts()
|
||||
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed'))
|
||||
|
||||
await expect(
|
||||
saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' })
|
||||
).rejects.toThrow('metadata write failed')
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate])
|
||||
await expect(
|
||||
loadHosts().then((hosts) => hosts.find(({ id }) => id === HOST_ONE.id)?.deviceToken)
|
||||
).resolves.toBe('replacement-token')
|
||||
})
|
||||
|
||||
it('records cleanup when new same-key metadata fails after its credential commits', async () => {
|
||||
const replacement = {
|
||||
...HOST_TWO,
|
||||
id: 'host-replacement',
|
||||
publicKeyB64: HOST_ONE.publicKeyB64,
|
||||
deviceToken: 'replacement-token'
|
||||
}
|
||||
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed'))
|
||||
|
||||
await expect(saveHost(replacement)).rejects.toThrow('metadata write failed')
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO])
|
||||
expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id)
|
||||
expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
secureStoreMock.setItemAsync.mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith(replacement.id, expect.any(Function))
|
||||
})
|
||||
|
||||
it('does not clean a duplicate id that a later same-key save made authoritative', async () => {
|
||||
const duplicate = {
|
||||
...HOST_TWO,
|
||||
id: 'host-duplicate',
|
||||
publicKeyB64: HOST_ONE.publicKeyB64
|
||||
}
|
||||
storedHostsRaw = JSON.stringify([HOST_ONE, duplicate])
|
||||
await saveHost({ ...HOST_ONE, deviceToken: 'token-a' })
|
||||
const staleCleanup = scheduledCleanup(duplicate.id)
|
||||
|
||||
await saveHost({ ...duplicate, deviceToken: 'token-b' })
|
||||
await staleCleanup(duplicate.id)
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([duplicate])
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let stale removal cleanup delete a replacement relay bundle before publication', async () => {
|
||||
await removeHost(HOST_ONE.id)
|
||||
const staleCleanup = scheduledCleanup(HOST_ONE.id)
|
||||
await writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)
|
||||
|
||||
await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup')
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps stale cleanup pending when a replacement relay write fails', async () => {
|
||||
await removeHost(HOST_ONE.id)
|
||||
const staleCleanup = scheduledCleanup(HOST_ONE.id)
|
||||
secureStoreMock.setItemAsync.mockRejectedValueOnce(new Error('keychain write failed'))
|
||||
|
||||
await expect(writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)).rejects.toThrow(
|
||||
'keychain write failed'
|
||||
)
|
||||
await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup')
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops stale cleanup when a replacement relay write starts during token deletion', async () => {
|
||||
await removeHost(HOST_ONE.id)
|
||||
const staleCleanup = scheduledCleanup(HOST_ONE.id)
|
||||
let releaseTokenDelete: () => void = () => {}
|
||||
secureStoreMock.deleteItemAsync.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseTokenDelete = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const cleanup = staleCleanup(HOST_ONE.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith(
|
||||
'orca.host-token.host-1',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
const bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)
|
||||
releaseTokenDelete()
|
||||
await bundleWrite
|
||||
await expect(cleanup).rejects.toThrow('credential write superseded cleanup')
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalledWith(
|
||||
'orca.mobile-relay.credentials.host-1',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('rechecks the write generation immediately before starting credential deletion', async () => {
|
||||
await removeHost(HOST_ONE.id)
|
||||
const staleCleanup = scheduledCleanup(HOST_ONE.id)
|
||||
let resolveCleanupRead: (raw: string) => void = () => {}
|
||||
const cleanupRead = new Promise<string>((resolve) => {
|
||||
resolveCleanupRead = resolve
|
||||
})
|
||||
let cleanupReadStarted = false
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === HOSTS_STORAGE_KEY && !cleanupReadStarted) {
|
||||
cleanupReadStarted = true
|
||||
return cleanupRead
|
||||
}
|
||||
if (key === HOSTS_STORAGE_KEY) {
|
||||
return storedHostsRaw
|
||||
}
|
||||
if (key === OVERLAY_STORAGE_KEY) {
|
||||
return storedOverlayRaw
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const cleanup = staleCleanup(HOST_ONE.id)
|
||||
await vi.waitFor(() => expect(cleanupReadStarted).toBe(true))
|
||||
let bundleWrite: Promise<void> | null = null
|
||||
resolveCleanupRead(storedHostsRaw)
|
||||
queueMicrotask(() => {
|
||||
bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)
|
||||
})
|
||||
await expect(cleanup).rejects.toThrow('credential write superseded cleanup')
|
||||
await vi.waitFor(() => expect(bundleWrite).not.toBeNull())
|
||||
await bundleWrite
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale relay state when an existing host is re-paired direct-only', async () => {
|
||||
const overlay: MobileRelayHostOverlay = {
|
||||
v: 2,
|
||||
@@ -209,7 +450,63 @@ describe('host-store list mutations', () => {
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
|
||||
})
|
||||
|
||||
it('merges v2 endpoints only onto an existing legacy base host', async () => {
|
||||
it('cancels write-ahead cleanup when host metadata removal fails', async () => {
|
||||
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed'))
|
||||
|
||||
await expect(removeHost(HOST_ONE.id)).rejects.toThrow('metadata write failed')
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO])
|
||||
expect(cancelCleanupMock).toHaveBeenCalledWith(HOST_ONE.id)
|
||||
})
|
||||
|
||||
it('does not cancel cleanup from a removal committed during a stalled save', async () => {
|
||||
let releaseTokenWrite: () => void = () => {}
|
||||
secureStoreMock.setItemAsync.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseTokenWrite = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' })
|
||||
await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce())
|
||||
await removeHost(HOST_ONE.id)
|
||||
releaseTokenWrite()
|
||||
await save
|
||||
await loadHostCatalog()
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
|
||||
expect(cancelCleanupMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an early same-key token write without durable cleanup intent', async () => {
|
||||
const replacement = {
|
||||
...HOST_TWO,
|
||||
id: 'host-replacement',
|
||||
publicKeyB64: HOST_ONE.publicKeyB64,
|
||||
deviceToken: 'replacement-token'
|
||||
}
|
||||
recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable'))
|
||||
|
||||
await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable')
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO])
|
||||
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes an unpaired credential when cleanup runs without a newer write', async () => {
|
||||
await removeHost(HOST_ONE.id)
|
||||
const cleanup = scheduledCleanup(HOST_ONE.id)
|
||||
|
||||
await cleanup(HOST_ONE.id)
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith(
|
||||
'orca.host-token.host-1',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a concurrently re-published orphan overlay authoritative', async () => {
|
||||
const overlay: MobileRelayHostOverlay = {
|
||||
v: 2,
|
||||
hostId: HOST_ONE.id,
|
||||
@@ -232,8 +529,26 @@ describe('host-store list mutations', () => {
|
||||
}
|
||||
}
|
||||
storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }])
|
||||
let releaseCleanup: () => void = () => {}
|
||||
scheduleCleanupMock.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const hosts = await loadHosts()
|
||||
const hostsLoad = loadHosts()
|
||||
await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled())
|
||||
await saveHost({
|
||||
...HOST_ONE,
|
||||
id: 'removed-by-old-build',
|
||||
publicKeyB64: 'restored-key',
|
||||
deviceToken: 'restored-token',
|
||||
endpoints: overlay.endpoints,
|
||||
relayHostId: overlay.relayHostId,
|
||||
relay: overlay.relay
|
||||
})
|
||||
releaseCleanup()
|
||||
const hosts = await hostsLoad
|
||||
|
||||
expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
|
||||
endpoints: overlay.endpoints,
|
||||
@@ -241,6 +556,10 @@ describe('host-store list mutations', () => {
|
||||
relay: overlay.relay
|
||||
})
|
||||
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
|
||||
expect(JSON.parse(storedOverlayRaw!)).toContainEqual({
|
||||
...overlay,
|
||||
hostId: 'removed-by-old-build'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
|
||||
@@ -270,7 +589,9 @@ describe('host-store list mutations', () => {
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
|
||||
})
|
||||
expect(settled).toBe(false)
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledOnce()
|
||||
expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
asyncStorageMock.setItem.mock.invocationCallOrder[0]!
|
||||
)
|
||||
|
||||
resolveSchedule?.()
|
||||
await removal
|
||||
@@ -522,6 +843,10 @@ describe('host-store pairing save after an Android encryption rejection', () =>
|
||||
resetMobileRelayHostOverlayStoreForTests()
|
||||
scheduleCleanupMock.mockReset()
|
||||
scheduleCleanupMock.mockResolvedValue(undefined)
|
||||
cancelCleanupMock.mockReset()
|
||||
cancelCleanupMock.mockResolvedValue(undefined)
|
||||
recordCleanupIntentMock.mockReset()
|
||||
recordCleanupIntentMock.mockResolvedValue(undefined)
|
||||
storedHostsRaw = '[]'
|
||||
storedGenerationRaw = null
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
|
||||
+183
-187
@@ -1,20 +1,13 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { Platform } from 'react-native'
|
||||
import {
|
||||
HostProfileSchema,
|
||||
StoredHostProfileSchema,
|
||||
type HostProfile,
|
||||
type StoredHostProfile
|
||||
} from './types'
|
||||
import { HostProfileSchema } from './types'
|
||||
import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types'
|
||||
import { getNextHostNameFromHosts } from './host-names'
|
||||
import * as hostListLoads from './host-list-load-sharing'
|
||||
import { joinHostCatalogCredentials } from './host-catalog-credential-join'
|
||||
import { resetPairingKeychainForTests } from './pairing-keychain'
|
||||
import { readHostDeviceToken, writeHostDeviceToken } from './host-device-token-store'
|
||||
import {
|
||||
deletePairingKeychainItem,
|
||||
readPairingKeychainItem,
|
||||
resetPairingKeychainForTests,
|
||||
writePairingKeychainItem
|
||||
} from './pairing-keychain'
|
||||
import {
|
||||
cancelPendingHostCredentialCleanup,
|
||||
recordHostCredentialCleanupIntent,
|
||||
retryPendingHostCredentialCleanups,
|
||||
scheduleHostCredentialCleanup
|
||||
} from './host-credential-cleanup'
|
||||
@@ -24,137 +17,67 @@ import {
|
||||
removeMobileRelayHostOverlays,
|
||||
saveMobileRelayHostOverlay
|
||||
} from './mobile-relay-host-overlay-store'
|
||||
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
|
||||
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
|
||||
import {
|
||||
getHostCredentialWriteRevision,
|
||||
markHostCredentialWrite,
|
||||
resetHostCredentialWriteRevisionsForTests
|
||||
} from './host-credential-write-revision'
|
||||
import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion'
|
||||
import {
|
||||
loadStoredHostProfiles,
|
||||
readStoredHostProfilesForMutation,
|
||||
toStoredHostProfile,
|
||||
writeStoredHostProfiles
|
||||
} from './host-metadata-store'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
|
||||
const TOKEN_KEY_PREFIX = 'orca.host-token.'
|
||||
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
|
||||
|
||||
function tokenKey(hostId: string): string {
|
||||
return `${TOKEN_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
|
||||
function webTokenKey(hostId: string): string {
|
||||
return `${WEB_TOKEN_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
|
||||
async function readDeviceToken(hostId: string): Promise<string | null> {
|
||||
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
|
||||
if (Platform.OS === 'web') {
|
||||
return AsyncStorage.getItem(webTokenKey(hostId))
|
||||
}
|
||||
return readPairingKeychainItem(tokenKey(hostId))
|
||||
}
|
||||
|
||||
async function writeDeviceToken(hostId: string, token: string): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.setItem(webTokenKey(hostId), token)
|
||||
return
|
||||
}
|
||||
await writePairingKeychainItem(tokenKey(hostId), token)
|
||||
}
|
||||
|
||||
async function deleteDeviceToken(hostId: string): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.removeItem(webTokenKey(hostId))
|
||||
return
|
||||
}
|
||||
await deletePairingKeychainItem(tokenKey(hostId))
|
||||
}
|
||||
|
||||
async function deleteHostCredentials(hostId: string): Promise<void> {
|
||||
await deleteDeviceToken(hostId)
|
||||
await deleteMobileRelayCredentialBundle(hostId)
|
||||
await deleteMobileRelayDirectUpgradeJournal(hostId)
|
||||
async function commitDeviceToken(hostId: string, token: string): Promise<void> {
|
||||
markHostCredentialWrite(hostId)
|
||||
await writeHostDeviceToken(hostId, token)
|
||||
tokenCache.set(hostId, token)
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
}
|
||||
|
||||
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
|
||||
const tokenCache = new Map<string, string>()
|
||||
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
|
||||
// Why: serialize host metadata RMW so concurrent writers cannot drop updates.
|
||||
let hostListMutation: Promise<void> = Promise.resolve()
|
||||
|
||||
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return parsed.flatMap((item) => {
|
||||
// Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
return []
|
||||
}
|
||||
const result = StoredHostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
export const loadHosts = async (): Promise<HostProfile[]> => (await loadHostListSnapshot()).profiles
|
||||
export const loadHostCatalog = async (): Promise<HostCatalogEntry[]> =>
|
||||
(await loadHostListSnapshot()).catalog
|
||||
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
async function loadHostListSnapshot(): Promise<hostListLoads.HostListSnapshot> {
|
||||
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
|
||||
await hostListMutation
|
||||
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
|
||||
return hostListLoads.shareHostListLoad(doLoadHosts)
|
||||
return hostListLoads.shareHostListLoad(doLoadHostListSnapshot)
|
||||
}
|
||||
|
||||
async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
const storedHosts = parseStoredHosts(raw)
|
||||
async function doLoadHostListSnapshot(): Promise<hostListLoads.HostListSnapshot> {
|
||||
const storedHosts = await loadStoredHostProfiles()
|
||||
if (!storedHosts) {
|
||||
return []
|
||||
return { catalog: [], profiles: [] }
|
||||
}
|
||||
const overlayState = await loadMobileRelayHostOverlayState(
|
||||
new Set(storedHosts.map(({ id }) => id))
|
||||
)
|
||||
const orphanWriteRevisions = new Map(
|
||||
overlayState.orphanHostIds.map((hostId) => [hostId, getHostCredentialWriteRevision(hostId)])
|
||||
)
|
||||
await scheduleOrphanedMobileRelayCleanup({
|
||||
hostIds: overlayState.orphanHostIds,
|
||||
deleteCredential: deleteHostCredentials
|
||||
deleteCredential: (hostId) =>
|
||||
deleteUnpairedHostCredentials(hostId, orphanWriteRevisions.get(hostId) ?? 0),
|
||||
removeOverlay: removeOrphanOverlayIfUnpaired
|
||||
})
|
||||
return joinHostCatalogCredentials({
|
||||
storedHosts,
|
||||
overlays: overlayState.overlays,
|
||||
tokenCache,
|
||||
readToken: readHostDeviceToken,
|
||||
getRevision: hostListLoads.getHostListLoadRevision
|
||||
})
|
||||
const overlays = overlayState.overlays
|
||||
|
||||
const out: HostProfile[] = []
|
||||
for (const stored of storedHosts) {
|
||||
let token = tokenCache.get(stored.id)
|
||||
if (!token) {
|
||||
const readRevision = hostListLoads.getHostListLoadRevision()
|
||||
let fetched: string | null
|
||||
try {
|
||||
fetched = await readDeviceToken(stored.id)
|
||||
} catch {
|
||||
// Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
|
||||
continue
|
||||
}
|
||||
if (!fetched) {
|
||||
// Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
|
||||
continue
|
||||
}
|
||||
token = fetched
|
||||
if (readRevision === hostListLoads.getHostListLoadRevision()) {
|
||||
tokenCache.set(stored.id, token)
|
||||
}
|
||||
}
|
||||
const overlay = overlays.get(stored.id)
|
||||
out.push({
|
||||
...stored,
|
||||
deviceToken: token,
|
||||
...(overlay
|
||||
? {
|
||||
endpoints: overlay.endpoints,
|
||||
relayHostId: overlay.relayHostId,
|
||||
relay: overlay.relay
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function resolvePairingHostIdentity(
|
||||
@@ -163,91 +86,146 @@ export async function resolvePairingHostIdentity(
|
||||
): Promise<{ id: string; name: string }> {
|
||||
// Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards.
|
||||
await hostListMutation
|
||||
const hosts = await readStoredHostsForMutation()
|
||||
const hosts = await readStoredHostProfilesForMutation()
|
||||
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
|
||||
return match
|
||||
? { id: match.id, name: match.name }
|
||||
: { id: newHostId, name: getNextHostNameFromHosts(hosts) }
|
||||
}
|
||||
|
||||
async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
|
||||
try {
|
||||
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
|
||||
if (!parsed) {
|
||||
// Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
|
||||
throw new Error('host list storage unreadable')
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'host list storage unreadable') {
|
||||
throw error
|
||||
}
|
||||
throw new Error('host list storage unreadable')
|
||||
const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({
|
||||
waitForHostMutations: () => hostListMutation,
|
||||
hasStoredHost: async (hostId) =>
|
||||
(await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId),
|
||||
onDeleted: (hostId) => {
|
||||
tokenCache.delete(hostId)
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
}
|
||||
})
|
||||
|
||||
function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise<void> {
|
||||
const writeRevision = getHostCredentialWriteRevision(hostId)
|
||||
return scheduleHostCredentialCleanup(hostId, (id) =>
|
||||
deleteUnpairedHostCredentials(id, writeRevision)
|
||||
)
|
||||
}
|
||||
|
||||
async function mutateStoredHosts(
|
||||
update: (hosts: StoredHostProfile[]) => StoredHostProfile[]
|
||||
): Promise<void> {
|
||||
const mutation = hostListMutation.then(async () => {
|
||||
const current = await readStoredHostsForMutation()
|
||||
const next = update(current)
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
function cancelCleanupForStoredHost(hostId: string): void {
|
||||
const cancellation = hostListMutation.then(async () => {
|
||||
const hosts = await readStoredHostProfilesForMutation()
|
||||
if (hosts.some(({ id }) => id === hostId)) {
|
||||
// Register before later removals enqueue their intent, without blocking host loads on cleanup storage.
|
||||
void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
hostListMutation = cancellation.catch(() => {})
|
||||
}
|
||||
|
||||
async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable<string>): Promise<void> {
|
||||
const targets = [...hostIds]
|
||||
return enqueueHostListMutation(async () => {
|
||||
const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id))
|
||||
await Promise.all(
|
||||
targets
|
||||
.filter((hostId) => storedIds.has(hostId))
|
||||
.map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined))
|
||||
)
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function enqueueHostListMutation(operation: () => Promise<void>): Promise<void> {
|
||||
const mutation = hostListMutation.then(operation)
|
||||
hostListMutation = mutation.catch(() => {})
|
||||
return mutation
|
||||
}
|
||||
|
||||
function toStored(host: HostProfile): StoredHostProfile {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
endpoint: host.endpoint,
|
||||
publicKeyB64: host.publicKeyB64,
|
||||
lastConnected: host.lastConnected
|
||||
}
|
||||
function removeOrphanOverlayIfUnpaired(hostId: string): Promise<void> {
|
||||
return enqueueHostListMutation(async () => {
|
||||
const hosts = await readStoredHostProfilesForMutation()
|
||||
if (!hosts.some(({ id }) => id === hostId)) {
|
||||
await removeMobileRelayHostOverlay(hostId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mutateStoredHosts(
|
||||
update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise<StoredHostProfile[]>
|
||||
): Promise<void> {
|
||||
return enqueueHostListMutation(async () => {
|
||||
const current = await readStoredHostProfilesForMutation()
|
||||
const next = await update(current)
|
||||
await writeStoredHostProfiles(next)
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
})
|
||||
}
|
||||
|
||||
export class MobileRelayUpgradeHostRemovedError extends Error {}
|
||||
|
||||
export async function saveHost(host: HostProfile): Promise<void> {
|
||||
await persistHost(host, false)
|
||||
}
|
||||
export const saveHost = (host: HostProfile): Promise<void> => persistHost(host, false)
|
||||
|
||||
export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise<void> {
|
||||
await persistHost(host, true)
|
||||
}
|
||||
export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise<void> =>
|
||||
persistHost(host, true)
|
||||
|
||||
async function persistHost(host: HostProfile, requireExisting: boolean): Promise<void> {
|
||||
const validated = HostProfileSchema.parse(host)
|
||||
const stored = toStored(validated)
|
||||
const stored = toStoredHostProfile(validated)
|
||||
const duplicateHostIds = new Set<string>()
|
||||
let updatedExistingHost = false
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === stored.id)
|
||||
for (const candidate of hosts) {
|
||||
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
|
||||
duplicateHostIds.add(candidate.id)
|
||||
let cleanupIntentRecordedBeforeMetadata = false
|
||||
let tokenCommittedBeforeMetadata = false
|
||||
try {
|
||||
await mutateStoredHosts(async (hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === stored.id)
|
||||
for (const candidate of hosts) {
|
||||
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
|
||||
duplicateHostIds.add(candidate.id)
|
||||
}
|
||||
}
|
||||
let next: StoredHostProfile[]
|
||||
if (index >= 0) {
|
||||
updatedExistingHost = true
|
||||
// Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
|
||||
next = hosts
|
||||
.filter(({ id }) => !duplicateHostIds.has(id))
|
||||
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
|
||||
} else if (requireExisting) {
|
||||
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
|
||||
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
|
||||
} else {
|
||||
next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
|
||||
}
|
||||
if (duplicateHostIds.size > 0) {
|
||||
if (index < 0) {
|
||||
// Why: process death between the early token write and metadata publication must leave cleanup discoverable.
|
||||
await recordHostCredentialCleanupIntent(stored.id)
|
||||
cleanupIntentRecordedBeforeMetadata = true
|
||||
}
|
||||
for (const duplicateHostId of duplicateHostIds) {
|
||||
await recordHostCredentialCleanupIntent(duplicateHostId)
|
||||
}
|
||||
// Why: never remove the only usable same-key row until its replacement credential is durable.
|
||||
await commitDeviceToken(stored.id, validated.deviceToken)
|
||||
tokenCommittedBeforeMetadata = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
await cancelCleanupForDurablyStoredHosts(duplicateHostIds)
|
||||
if (cleanupIntentRecordedBeforeMetadata) {
|
||||
try {
|
||||
await scheduleUnpairedHostCredentialCleanup(stored.id)
|
||||
} catch {
|
||||
// The write-ahead cleanup intent remains available for retry.
|
||||
}
|
||||
}
|
||||
if (index >= 0) {
|
||||
updatedExistingHost = true
|
||||
// Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
|
||||
return hosts
|
||||
.filter(({ id }) => !duplicateHostIds.has(id))
|
||||
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
|
||||
}
|
||||
if (requireExisting) {
|
||||
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
|
||||
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
|
||||
}
|
||||
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
|
||||
})
|
||||
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
|
||||
await writeDeviceToken(stored.id, validated.deviceToken)
|
||||
tokenCache.set(stored.id, validated.deviceToken)
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
throw error
|
||||
}
|
||||
if (!tokenCommittedBeforeMetadata) {
|
||||
// Why: the catalog can now surface a failed token write for recovery instead of losing the host.
|
||||
await commitDeviceToken(stored.id, validated.deviceToken)
|
||||
}
|
||||
// Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative.
|
||||
cancelCleanupForStoredHost(stored.id)
|
||||
if (validated.endpoints) {
|
||||
await saveMobileRelayHostOverlay({
|
||||
v: 2,
|
||||
@@ -268,9 +246,8 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
}
|
||||
for (const duplicateHostId of duplicateHostIds) {
|
||||
tokenCache.delete(duplicateHostId)
|
||||
try {
|
||||
await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials)
|
||||
await scheduleUnpairedHostCredentialCleanup(duplicateHostId)
|
||||
} catch {
|
||||
// Metadata is already deduplicated; orphan-token recovery is best-effort.
|
||||
}
|
||||
@@ -278,7 +255,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
|
||||
}
|
||||
|
||||
export async function removeHost(hostId: string): Promise<void> {
|
||||
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
|
||||
let cleanupIntentRecorded = false
|
||||
try {
|
||||
await mutateStoredHosts(async (hosts) => {
|
||||
try {
|
||||
await recordHostCredentialCleanupIntent(hostId)
|
||||
cleanupIntentRecorded = true
|
||||
} catch {
|
||||
// Removal remains authoritative when cleanup intent storage is unavailable.
|
||||
}
|
||||
return hosts.filter((h) => h.id !== hostId)
|
||||
})
|
||||
} catch (error) {
|
||||
if (cleanupIntentRecorded) {
|
||||
await cancelCleanupForDurablyStoredHosts([hostId])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
tokenCache.delete(hostId)
|
||||
try {
|
||||
await removeMobileRelayHostOverlay(hostId)
|
||||
@@ -288,7 +281,7 @@ export async function removeHost(hostId: string): Promise<void> {
|
||||
}
|
||||
// Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI.
|
||||
try {
|
||||
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
|
||||
await scheduleUnpairedHostCredentialCleanup(hostId)
|
||||
} catch {
|
||||
// Metadata is already committed; orphan-token recovery is best-effort.
|
||||
}
|
||||
@@ -299,7 +292,9 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
|
||||
remainingIds: string[]
|
||||
storageUnreadable: boolean
|
||||
}> {
|
||||
return retryPendingHostCredentialCleanups(deleteHostCredentials)
|
||||
return retryPendingHostCredentialCleanups((hostId) =>
|
||||
deleteUnpairedHostCredentials(hostId, getHostCredentialWriteRevision(hostId))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other.
|
||||
@@ -342,6 +337,7 @@ export async function updateLastConnected(hostId: string): Promise<void> {
|
||||
export function resetHostStoreForTests(): void {
|
||||
hostListMutation = Promise.resolve()
|
||||
tokenCache.clear()
|
||||
resetHostCredentialWriteRevisionsForTests()
|
||||
hostListLoads.dropSharedHostListLoad()
|
||||
resetPairingKeychainForTests()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readPairingKeychainItem,
|
||||
writePairingKeychainItem
|
||||
} from './pairing-keychain'
|
||||
import { markHostCredentialWrite } from './host-credential-write-revision'
|
||||
|
||||
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
|
||||
const ResumeCredentialSchema = z
|
||||
@@ -91,6 +92,7 @@ export async function writeMobileRelayCredentialBundle(
|
||||
): Promise<void> {
|
||||
requireNativeSecretStore()
|
||||
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
|
||||
markHostCredentialWrite(validated.hostId)
|
||||
await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readPairingKeychainItem,
|
||||
writePairingKeychainItem
|
||||
} from './pairing-keychain'
|
||||
import { markHostCredentialWrite } from './host-credential-write-revision'
|
||||
|
||||
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
|
||||
|
||||
@@ -60,6 +61,7 @@ export async function writeMobileRelayDirectUpgradeJournal(
|
||||
): Promise<void> {
|
||||
requireNativeSecretStore()
|
||||
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
|
||||
markHostCredentialWrite(parsed.hostId)
|
||||
await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import { useAllHostClients } from './use-all-host-clients'
|
||||
|
||||
export function useFocusedSettingsHostClients(hostIds: string[]) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setFocused(true)
|
||||
return () => setFocused(false)
|
||||
}, [])
|
||||
)
|
||||
|
||||
const clients = useAllHostClients(focused ? hostIds : [], {
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
return { clients, focused }
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
import { createElement, Fragment, useEffect } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAllHostClients } from './use-all-host-clients'
|
||||
import { useFocusedSettingsHostClients } from './settings-host-client-connections'
|
||||
import {
|
||||
RpcClientProvider,
|
||||
useHostClient,
|
||||
usePrimeHosts,
|
||||
useRpcClientContext,
|
||||
type RpcClientContextValue
|
||||
} from './client-context'
|
||||
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
const connectMock = vi.fn()
|
||||
const loadHostsMock = vi.fn()
|
||||
const routeFocus = vi.hoisted(() => ({
|
||||
effect: null as null | (() => void | (() => void))
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useFocusEffect: (effect: () => void | (() => void)) => {
|
||||
routeFocus.effect = effect
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./host-logical-client', () => ({
|
||||
openHostLogicalClient: (...args: unknown[]) => connectMock(...args)
|
||||
}))
|
||||
vi.mock('./host-store', () => ({
|
||||
loadHosts: () => loadHostsMock()
|
||||
}))
|
||||
vi.mock('./connection-revival-triggers', () => ({
|
||||
subscribeConnectionRevivalTriggers: () => () => {}
|
||||
}))
|
||||
|
||||
type FakeClient = RpcClient & {
|
||||
closeMock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function makeFakeClient(initialState: ConnectionState): FakeClient {
|
||||
const listeners = new Set<(state: ConnectionState) => void>()
|
||||
const closeMock = vi.fn()
|
||||
return {
|
||||
sendRequest: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => initialState,
|
||||
getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0),
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
notifyForeground: vi.fn(),
|
||||
close: closeMock,
|
||||
closeMock
|
||||
} as FakeClient
|
||||
}
|
||||
|
||||
function host(id: string, lastConnected: number, extra: Partial<HostProfile> = {}): HostProfile {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
endpoint: `ws://${id}.internal:8787`,
|
||||
deviceToken: `token-${id}`,
|
||||
publicKeyB64: `key-${id}`,
|
||||
lastConnected,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTS = [
|
||||
host('direct-recent', 50),
|
||||
host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }),
|
||||
host('ssh-provider-recent', 30),
|
||||
host('folder-workspace-host', 20),
|
||||
host('offline-host', 10)
|
||||
]
|
||||
const HOST_IDS = HOSTS.map((profile) => profile.id)
|
||||
const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS)
|
||||
|
||||
let context: RpcClientContextValue | null = null
|
||||
|
||||
function ContextProbe(): null {
|
||||
context = useRpcClientContext()
|
||||
return null
|
||||
}
|
||||
|
||||
function HomeProbe(): null {
|
||||
useAllHostClients(HOST_IDS, {
|
||||
autoConnectHostIds: HOME_HOST_IDS,
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
const primeHosts = usePrimeHosts()
|
||||
useEffect(() => {
|
||||
primeHosts(HOSTS)
|
||||
}, [primeHosts])
|
||||
return null
|
||||
}
|
||||
|
||||
function SettingsProbe(): null {
|
||||
useAllHostClients(HOST_IDS, {
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function FocusedSettingsProbe(): null {
|
||||
useFocusedSettingsHostClients(HOST_IDS)
|
||||
return null
|
||||
}
|
||||
|
||||
function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null {
|
||||
useAllHostClients(hostIds, { closeUnusedOnRelease: true })
|
||||
return null
|
||||
}
|
||||
|
||||
function DetailProbe({ hostId }: { hostId: string }): null {
|
||||
useHostClient(hostId)
|
||||
return null
|
||||
}
|
||||
|
||||
type Screen = 'empty' | 'home' | 'settings'
|
||||
|
||||
function TestApp({
|
||||
screen,
|
||||
detailHostId
|
||||
}: {
|
||||
screen: Screen
|
||||
detailHostId?: string
|
||||
}): React.JSX.Element {
|
||||
const screenProbe =
|
||||
screen === 'home'
|
||||
? createElement(HomeProbe)
|
||||
: screen === 'settings'
|
||||
? createElement(SettingsProbe)
|
||||
: null
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null,
|
||||
screenProbe
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderScreen(screen: Screen, detailHostId?: string): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(TestApp, { screen, detailHostId }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('settings lifecycle harness did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
async function navigate(
|
||||
renderer: ReactTestRenderer,
|
||||
screen: Screen,
|
||||
detailHostId?: string
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer.update(createElement(TestApp, { screen, detailHostId }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function activeHostIds(): string[] {
|
||||
if (!context) {
|
||||
throw new Error('client context was not captured')
|
||||
}
|
||||
return context
|
||||
.getAllClients()
|
||||
.map(({ hostId }) => hostId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
context = null
|
||||
routeFocus.effect = null
|
||||
connectMock.mockReset()
|
||||
loadHostsMock.mockReset()
|
||||
})
|
||||
|
||||
describe('settings host client lifecycle', () => {
|
||||
it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => {
|
||||
const clients = new Map<string, FakeClient[]>()
|
||||
connectMock.mockImplementation((profile: HostProfile) => {
|
||||
const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected')
|
||||
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(HOSTS)
|
||||
|
||||
function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
createElement(HomeProbe),
|
||||
settingsVisible ? createElement(SettingsProbe) : null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NavigationStack, { settingsVisible: false }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('navigation stack harness did not render')
|
||||
}
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
|
||||
expect(connectMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(NavigationStack, { settingsVisible: true }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
|
||||
expect(connectMock).toHaveBeenCalledTimes(HOSTS.length)
|
||||
expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(NavigationStack, { settingsVisible: false }))
|
||||
})
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
|
||||
for (const hostId of HOME_HOST_IDS) {
|
||||
expect(clients.get(hostId)).toHaveLength(1)
|
||||
expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled()
|
||||
}
|
||||
for (const hostId of ['folder-workspace-host', 'offline-host']) {
|
||||
expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(NavigationStack, { settingsVisible: true }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(NavigationStack, { settingsVisible: false }))
|
||||
})
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
|
||||
for (const hostId of HOME_HOST_IDS) {
|
||||
expect(clients.get(hostId)).toHaveLength(1)
|
||||
}
|
||||
for (const hostId of ['folder-workspace-host', 'offline-host']) {
|
||||
expect(clients.get(hostId)).toHaveLength(2)
|
||||
expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('leaves no zero-reference clients after repeated settings navigation', async () => {
|
||||
const clients = new Map<string, FakeClient[]>()
|
||||
connectMock.mockImplementation((profile: HostProfile) => {
|
||||
const client = makeFakeClient('reconnecting')
|
||||
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(HOSTS)
|
||||
|
||||
const renderer = await renderScreen('settings')
|
||||
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
|
||||
|
||||
await navigate(renderer, 'empty')
|
||||
expect(activeHostIds()).toEqual([])
|
||||
for (const hostClients of clients.values()) {
|
||||
expect(hostClients).toHaveLength(1)
|
||||
expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
await navigate(renderer, 'settings')
|
||||
await navigate(renderer, 'empty')
|
||||
expect(activeHostIds()).toEqual([])
|
||||
expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2)
|
||||
for (const hostClients of clients.values()) {
|
||||
expect(hostClients).toHaveLength(2)
|
||||
expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true)
|
||||
}
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('reconciles host-list changes without restarting retained or shared clients', async () => {
|
||||
const clients = new Map<string, FakeClient>()
|
||||
connectMock.mockImplementation((profile: HostProfile) => {
|
||||
const client = makeFakeClient('reconnecting')
|
||||
clients.set(profile.id, client)
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(HOSTS)
|
||||
|
||||
const retainedHostId = 'direct-recent'
|
||||
const sharedHostId = 'relay-recent'
|
||||
const removedHostId = 'folder-workspace-host'
|
||||
const addedHostId = 'offline-host'
|
||||
function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
createElement(DetailProbe, { hostId: sharedHostId }),
|
||||
createElement(DynamicSettingsProbe, { hostIds: settingsHostIds })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(HostListApp, {
|
||||
settingsHostIds: [retainedHostId, sharedHostId, removedHostId]
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('host-list lifecycle harness did not render')
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(HostListApp, {
|
||||
settingsHostIds: [retainedHostId, addedHostId]
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(connectMock).toHaveBeenCalledTimes(4)
|
||||
expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort())
|
||||
expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled()
|
||||
expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled()
|
||||
expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce()
|
||||
expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => renderer.unmount())
|
||||
expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce()
|
||||
expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce()
|
||||
expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not close a settings client still held by an active consumer', async () => {
|
||||
const clients = new Map<string, FakeClient>()
|
||||
connectMock.mockImplementation((profile: HostProfile) => {
|
||||
const client = makeFakeClient('connected')
|
||||
clients.set(profile.id, client)
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(HOSTS)
|
||||
|
||||
const detailHostId = 'folder-workspace-host'
|
||||
const renderer = await renderScreen('settings', detailHostId)
|
||||
await navigate(renderer, 'home', detailHostId)
|
||||
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
|
||||
expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => renderer.unmount())
|
||||
expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a reconnect alive when another consumer remains after settings leaves', async () => {
|
||||
const retryHost = host('ssh-retry-host', 1)
|
||||
let resolveInitial: ((hosts: HostProfile[]) => void) | null = null
|
||||
let resolveRetry: ((hosts: HostProfile[]) => void) | null = null
|
||||
const initialLookup = new Promise<HostProfile[]>((resolve) => {
|
||||
resolveInitial = resolve
|
||||
})
|
||||
const retryLookup = new Promise<HostProfile[]>((resolve) => {
|
||||
resolveRetry = resolve
|
||||
})
|
||||
loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup)
|
||||
const initialClient = makeFakeClient('connected')
|
||||
const retryClient = makeFakeClient('reconnecting')
|
||||
connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient)
|
||||
|
||||
function RetrySettingsProbe(): null {
|
||||
useAllHostClients([retryHost.id], {
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
return null
|
||||
}
|
||||
function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
createElement(DetailProbe, { hostId: retryHost.id }),
|
||||
settingsVisible ? createElement(RetrySettingsProbe) : null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RetryApp, { settingsVisible: true }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer || !resolveInitial || !resolveRetry) {
|
||||
throw new Error('retry lifecycle harness did not initialize')
|
||||
}
|
||||
await act(async () => {
|
||||
resolveInitial?.([retryHost])
|
||||
await initialLookup
|
||||
})
|
||||
if (!context) {
|
||||
throw new Error('client context was not captured')
|
||||
}
|
||||
|
||||
const reconnect = context.forceReconnect(retryHost.id)
|
||||
expect(initialClient.closeMock).toHaveBeenCalledOnce()
|
||||
act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false })))
|
||||
await act(async () => {
|
||||
resolveRetry?.([retryHost])
|
||||
await retryLookup
|
||||
await reconnect
|
||||
})
|
||||
|
||||
expect(activeHostIds()).toEqual([retryHost.id])
|
||||
expect(retryClient.closeMock).not.toHaveBeenCalled()
|
||||
act(() => renderer?.unmount())
|
||||
expect(retryClient.closeMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels a released open without cancelling a rapid replacement acquisition', async () => {
|
||||
const settingsOnlyHost = host('settings-only-offline', 0, {
|
||||
deviceToken: '',
|
||||
publicKeyB64: ''
|
||||
})
|
||||
let resolveFirst: ((hosts: HostProfile[]) => void) | null = null
|
||||
let resolveSecond: ((hosts: HostProfile[]) => void) | null = null
|
||||
const firstLookup = new Promise<HostProfile[]>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondLookup = new Promise<HostProfile[]>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup)
|
||||
const client = makeFakeClient('reconnecting')
|
||||
connectMock.mockReturnValue(client)
|
||||
|
||||
function PendingSettingsProbe(): null {
|
||||
useAllHostClients([settingsOnlyHost.id], {
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
return null
|
||||
}
|
||||
function PendingApp({ visible }: { visible: boolean }): React.JSX.Element {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
visible ? createElement(PendingSettingsProbe) : null
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(PendingApp, { visible: true }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer || !resolveFirst || !resolveSecond) {
|
||||
throw new Error('pending settings harness did not initialize')
|
||||
}
|
||||
|
||||
act(() => renderer?.update(createElement(PendingApp, { visible: false })))
|
||||
act(() => renderer?.update(createElement(PendingApp, { visible: true })))
|
||||
await act(async () => {
|
||||
resolveFirst?.([settingsOnlyHost])
|
||||
await firstLookup
|
||||
})
|
||||
expect(connectMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
resolveSecond?.([settingsOnlyHost])
|
||||
await secondLookup
|
||||
})
|
||||
expect(connectMock).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => renderer?.update(createElement(PendingApp, { visible: false })))
|
||||
expect(client.closeMock).toHaveBeenCalledOnce()
|
||||
act(() => renderer?.unmount())
|
||||
})
|
||||
|
||||
it('preserves Home ownership when re-pairing replaces a selected host client', async () => {
|
||||
const clients = new Map<string, FakeClient[]>()
|
||||
connectMock.mockImplementation((profile: HostProfile) => {
|
||||
const client = makeFakeClient('connected')
|
||||
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(HOSTS)
|
||||
const replacedHostId = HOME_HOST_IDS[0]!
|
||||
|
||||
function ReplacementApp({
|
||||
detailVisible,
|
||||
settingsVisible
|
||||
}: {
|
||||
detailVisible: boolean
|
||||
settingsVisible: boolean
|
||||
}): React.JSX.Element {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
createElement(HomeProbe),
|
||||
detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null,
|
||||
settingsVisible ? createElement(SettingsProbe) : null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer || !context) {
|
||||
throw new Error('replacement lifecycle harness did not initialize')
|
||||
}
|
||||
|
||||
const originalClient = clients.get(replacedHostId)?.[0]
|
||||
act(() => context?.closeHost(replacedHostId))
|
||||
expect(originalClient?.closeMock).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(ReplacementApp, { detailVisible: true, settingsVisible: false })
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const replacementClient = clients.get(replacedHostId)?.[1]
|
||||
expect(replacementClient).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(ReplacementApp, { detailVisible: false, settingsVisible: true })
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
act(() =>
|
||||
renderer?.update(
|
||||
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
|
||||
)
|
||||
)
|
||||
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
|
||||
expect(replacementClient?.closeMock).not.toHaveBeenCalled()
|
||||
act(() => renderer?.unmount())
|
||||
expect(replacementClient?.closeMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases a manual host after Home demotes or stops tracking it', async () => {
|
||||
const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' })
|
||||
const clients: FakeClient[] = []
|
||||
connectMock.mockImplementation(() => {
|
||||
const client = makeFakeClient('reconnecting')
|
||||
clients.push(client)
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue([manualHost])
|
||||
|
||||
function ManualHomeProbe({ selected }: { selected: boolean }): null {
|
||||
useAllHostClients([manualHost.id], {
|
||||
autoConnectHostIds: selected ? [manualHost.id] : [],
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
return null
|
||||
}
|
||||
function ManualApp({
|
||||
selected,
|
||||
homeVisible = true
|
||||
}: {
|
||||
selected: boolean
|
||||
homeVisible?: boolean
|
||||
}) {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
homeVisible ? createElement(ManualHomeProbe, { selected }) : null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(ManualApp, { selected: false }))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer || !context) {
|
||||
throw new Error('manual connection harness did not initialize')
|
||||
}
|
||||
await act(async () => {
|
||||
await context?.forceReconnect(manualHost.id)
|
||||
})
|
||||
expect(activeHostIds()).toEqual([manualHost.id])
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(ManualApp, { selected: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(activeHostIds()).toEqual([manualHost.id])
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(ManualApp, { selected: false }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(activeHostIds()).toEqual([])
|
||||
expect(clients[0]?.closeMock).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
await context?.forceReconnect(manualHost.id)
|
||||
})
|
||||
expect(activeHostIds()).toEqual([manualHost.id])
|
||||
expect(clients).toHaveLength(2)
|
||||
|
||||
act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false })))
|
||||
expect(activeHostIds()).toEqual([])
|
||||
expect(clients[1]?.closeMock).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => renderer?.unmount())
|
||||
expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('releases settings-only clients on blur while shared owners stay mounted', async () => {
|
||||
const clients = new Map<string, FakeClient>()
|
||||
connectMock.mockImplementation((profile: HostProfile) => {
|
||||
const client = makeFakeClient('reconnecting')
|
||||
clients.set(profile.id, client)
|
||||
return client
|
||||
})
|
||||
loadHostsMock.mockResolvedValue(HOSTS)
|
||||
const detailHostId = 'folder-workspace-host'
|
||||
|
||||
function FocusStack(): React.JSX.Element {
|
||||
return createElement(
|
||||
RpcClientProvider,
|
||||
null,
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(ContextProbe),
|
||||
createElement(HomeProbe),
|
||||
createElement(DetailProbe, { hostId: detailHostId }),
|
||||
createElement(FocusedSettingsProbe)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(FocusStack))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer || !routeFocus.effect) {
|
||||
throw new Error('settings focus harness did not initialize')
|
||||
}
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
|
||||
|
||||
let blur: (() => void) | undefined
|
||||
await act(async () => {
|
||||
const cleanup = routeFocus.effect?.()
|
||||
if (typeof cleanup === 'function') {
|
||||
blur = cleanup
|
||||
}
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
|
||||
|
||||
await act(async () => {
|
||||
blur?.()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
|
||||
for (const hostId of [...HOME_HOST_IDS, detailHostId]) {
|
||||
expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => renderer?.unmount())
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,13 @@ export type HostProfile = {
|
||||
relay?: MobileRelayHostOverlay['relay']
|
||||
}
|
||||
|
||||
export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing'
|
||||
|
||||
export type HostCatalogEntry = Omit<HostProfile, 'deviceToken'> & {
|
||||
credentialStatus: HostCredentialStatus
|
||||
profile: HostProfile | null
|
||||
}
|
||||
|
||||
export const HostProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { deleteHostDeviceToken } from './host-device-token-store'
|
||||
import {
|
||||
clearHostCredentialWriteRevision,
|
||||
getHostCredentialWriteRevision
|
||||
} from './host-credential-write-revision'
|
||||
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
|
||||
|
||||
type DeletionDependencies = {
|
||||
waitForHostMutations: () => Promise<void>
|
||||
hasStoredHost: (hostId: string) => Promise<boolean>
|
||||
onDeleted: (hostId: string) => void
|
||||
}
|
||||
|
||||
export function createUnpairedHostCredentialDeletion(dependencies: DeletionDependencies) {
|
||||
function writeRevisionChanged(hostId: string, writeRevision: number): boolean {
|
||||
return getHostCredentialWriteRevision(hostId) !== writeRevision
|
||||
}
|
||||
|
||||
function assertWriteRevisionUnchanged(hostId: string, writeRevision: number): void {
|
||||
if (writeRevisionChanged(hostId, writeRevision)) {
|
||||
throw new Error('credential write superseded cleanup')
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldSkip(hostId: string, writeRevision: number): Promise<boolean> {
|
||||
await dependencies.waitForHostMutations()
|
||||
if (await dependencies.hasStoredHost(hostId)) {
|
||||
return true
|
||||
}
|
||||
assertWriteRevisionUnchanged(hostId, writeRevision)
|
||||
return false
|
||||
}
|
||||
|
||||
return async (hostId: string, writeRevision: number): Promise<void> => {
|
||||
if (await shouldSkip(hostId, writeRevision)) {
|
||||
return
|
||||
}
|
||||
assertWriteRevisionUnchanged(hostId, writeRevision)
|
||||
await deleteHostDeviceToken(hostId)
|
||||
if (await shouldSkip(hostId, writeRevision)) {
|
||||
return
|
||||
}
|
||||
assertWriteRevisionUnchanged(hostId, writeRevision)
|
||||
await deleteMobileRelayCredentialBundle(hostId)
|
||||
if (await shouldSkip(hostId, writeRevision)) {
|
||||
return
|
||||
}
|
||||
assertWriteRevisionUnchanged(hostId, writeRevision)
|
||||
await deleteMobileRelayDirectUpgradeJournal(hostId)
|
||||
if (await shouldSkip(hostId, writeRevision)) {
|
||||
return
|
||||
}
|
||||
assertWriteRevisionUnchanged(hostId, writeRevision)
|
||||
clearHostCredentialWriteRevision(hostId)
|
||||
dependencies.onDeleted(hostId)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
|
||||
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
|
||||
export function useAllHostClients(hostIds: string[]) {
|
||||
type UseAllHostClientsOptions = {
|
||||
autoConnectHostIds?: readonly string[]
|
||||
closeUnusedOnRelease?: boolean
|
||||
}
|
||||
|
||||
export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) {
|
||||
const ctx = useRpcClientContext()
|
||||
// Stable key so we don't tear down on every render of the array.
|
||||
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
|
||||
const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds
|
||||
const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false
|
||||
const key = useMemo(
|
||||
() =>
|
||||
[
|
||||
[...hostIds].sort().join(','),
|
||||
[...autoConnectHostIds].sort().join(','),
|
||||
closeUnusedOnRelease ? 'close' : 'keep'
|
||||
].join('|'),
|
||||
[autoConnectHostIds, closeUnusedOnRelease, hostIds]
|
||||
)
|
||||
const [tick, setTick] = useState(0)
|
||||
const acquiredHostIdsRef = useRef<Set<string>>(new Set())
|
||||
const hostUnsubscribesRef = useRef<Map<string, () => void>>(new Map())
|
||||
const closeUnusedRef = useRef(closeUnusedOnRelease)
|
||||
|
||||
useEffect(() => {
|
||||
if (hostIds.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const id of hostIds) {
|
||||
ctx.acquire(id)
|
||||
}
|
||||
const unsubs: (() => void)[] = []
|
||||
for (const id of hostIds) {
|
||||
unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1)))
|
||||
}
|
||||
unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1)))
|
||||
closeUnusedRef.current = closeUnusedOnRelease
|
||||
}, [closeUnusedOnRelease])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1))
|
||||
return () => {
|
||||
for (const u of unsubs) {
|
||||
u()
|
||||
unsubscribeAllHosts()
|
||||
const trackedHostIds = [...hostUnsubscribesRef.current.keys()]
|
||||
const acquiredHostIds = new Set(acquiredHostIdsRef.current)
|
||||
for (const unsubscribe of hostUnsubscribesRef.current.values()) {
|
||||
unsubscribe()
|
||||
}
|
||||
for (const id of hostIds) {
|
||||
ctx.release(id)
|
||||
hostUnsubscribesRef.current.clear()
|
||||
for (const id of acquiredHostIds) {
|
||||
if (closeUnusedRef.current) {
|
||||
ctx.releaseAndCloseIfUnused(id)
|
||||
} else {
|
||||
ctx.release(id)
|
||||
}
|
||||
}
|
||||
if (closeUnusedRef.current) {
|
||||
for (const id of trackedHostIds) {
|
||||
if (!acquiredHostIds.has(id)) {
|
||||
ctx.closeIfUnused(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
acquiredHostIdsRef.current.clear()
|
||||
}
|
||||
}, [ctx])
|
||||
|
||||
useEffect(() => {
|
||||
const trackedHostIds = new Set(hostIds)
|
||||
const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id)))
|
||||
const removedTrackedHostIds: string[] = []
|
||||
|
||||
for (const [id, unsubscribe] of hostUnsubscribesRef.current) {
|
||||
if (!trackedHostIds.has(id)) {
|
||||
unsubscribe()
|
||||
hostUnsubscribesRef.current.delete(id)
|
||||
removedTrackedHostIds.push(id)
|
||||
}
|
||||
}
|
||||
}, [key])
|
||||
for (const id of trackedHostIds) {
|
||||
if (!hostUnsubscribesRef.current.has(id)) {
|
||||
hostUnsubscribesRef.current.set(
|
||||
id,
|
||||
ctx.subscribeHostState(id, () => setTick((value) => value + 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of acquiredHostIdsRef.current) {
|
||||
if (!nextAcquiredHostIds.has(id)) {
|
||||
if (closeUnusedOnRelease) {
|
||||
ctx.releaseAndCloseIfUnused(id)
|
||||
} else {
|
||||
ctx.release(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of nextAcquiredHostIds) {
|
||||
if (!acquiredHostIdsRef.current.has(id)) {
|
||||
ctx.acquire(id)
|
||||
}
|
||||
}
|
||||
if (closeUnusedOnRelease) {
|
||||
for (const id of removedTrackedHostIds) {
|
||||
ctx.closeIfUnused(id)
|
||||
}
|
||||
for (const id of trackedHostIds) {
|
||||
if (!nextAcquiredHostIds.has(id)) {
|
||||
ctx.closeIfUnused(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
acquiredHostIdsRef.current = nextAcquiredHostIds
|
||||
}, [ctx, key])
|
||||
|
||||
return useMemo(() => {
|
||||
const out: {
|
||||
const clientsByHostId = new Map(
|
||||
ctx.getAllClients().map((entry) => [entry.hostId, entry.client])
|
||||
)
|
||||
return hostIds.flatMap<{
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
path: MobileConnectionPath
|
||||
}[] = []
|
||||
for (const id of hostIds) {
|
||||
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
|
||||
if (all) {
|
||||
out.push({
|
||||
hostId: id,
|
||||
client: all.client,
|
||||
state: ctx.getState(id),
|
||||
path: ctx.getActivePath(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [key, tick])
|
||||
}>((hostId) => {
|
||||
const client = clientsByHostId.get(hostId)
|
||||
return client
|
||||
? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }]
|
||||
: []
|
||||
})
|
||||
}, [ctx, hostIds, tick])
|
||||
}
|
||||
|
||||
@@ -192,6 +192,14 @@ orca orchestration worker-start --task <task_b> --worktree current --agent claud
|
||||
|
||||
`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal <handle>`.
|
||||
|
||||
For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.
|
||||
|
||||
For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:
|
||||
|
||||
```bash
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { printResult } from '../format'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
|
||||
describe('orchestration worker-start CLI contract', () => {
|
||||
beforeEach(() => {
|
||||
@@ -84,6 +85,63 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('capability-gates and forwards per-invocation launch preferences', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY]
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
runId: 'run_1',
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_1',
|
||||
state: 'ready',
|
||||
effects: [],
|
||||
residualResources: []
|
||||
}
|
||||
})
|
||||
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'claude'],
|
||||
['model', 'aws-bedrock-opus-5'],
|
||||
['effort', 'high'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'status.get')
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'orchestration.workerStart',
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
model: 'aws-bedrock-opus-5',
|
||||
effort: 'high'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails before worker-start when the runtime would strip launch preferences', async () => {
|
||||
callMock.mockResolvedValueOnce({ result: { capabilities: [] } })
|
||||
|
||||
await expect(
|
||||
invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'codex'],
|
||||
['model', 'gpt-5.6-sol'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'incompatible_runtime' })
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sets an unsuccessful exit code for failed and unknown receipts', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
|
||||
@@ -20,7 +20,8 @@ import type {
|
||||
OrchestrationWorkerReadSource
|
||||
} from '../../shared/orchestration-worker-output'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
|
||||
import {
|
||||
@@ -852,6 +853,21 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration worker-start': async ({ flags, client, cwd, json }) => {
|
||||
const model = getOptionalStringFlag(flags, 'model')
|
||||
const effort = getOptionalStringFlag(flags, 'effort')
|
||||
if (model || effort) {
|
||||
const status = await client.call<RuntimeStatus>('status.get')
|
||||
if (
|
||||
!status.result.capabilities?.includes(
|
||||
ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY
|
||||
)
|
||||
) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = await callMutation<{
|
||||
runId: string
|
||||
taskId: string
|
||||
@@ -873,6 +889,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
setup: getOptionalStringFlag(flags, 'setup'),
|
||||
agent: getOptionalStringFlag(flags, 'agent'),
|
||||
model,
|
||||
effort,
|
||||
terminal: getOptionalStringFlag(flags, 'terminal'),
|
||||
retryOf: getOptionalStringFlag(flags, 'retry-of'),
|
||||
timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'),
|
||||
|
||||
@@ -552,6 +552,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
local: '--local Target the current project instead of the global install',
|
||||
skill: '--skill <name> Bundled skill to act on; repeat for several',
|
||||
mode: '--mode <mode> Mode such as edit, diff, or both',
|
||||
model: '--model <id> Provider model id for a new agent launch',
|
||||
effort: '--effort <level> Reasoning effort for the selected model',
|
||||
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
|
||||
modifiers: '--modifiers <chord> Modifier keys held only for this click',
|
||||
name: '--name <name> Name for the new worktree or automation',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { launchOrcaApp } from './launch'
|
||||
import { addEnvironmentFromPairingCode } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
@@ -61,7 +62,10 @@ describe('CLI remote WebSocket transport', () => {
|
||||
expect(response.result.runtimeId).toBe('runtime-ws-1')
|
||||
expect(runtime.authFrames).toContainEqual(
|
||||
expect.objectContaining({
|
||||
clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY]
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-start'],
|
||||
summary: 'Start one supervised worker on the Run home or a connected Orca server',
|
||||
usage:
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
@@ -18,6 +18,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
'comment',
|
||||
'setup',
|
||||
'agent',
|
||||
'model',
|
||||
'effort',
|
||||
'terminal',
|
||||
'retry-of',
|
||||
'timeout-ms',
|
||||
@@ -27,6 +29,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
|
||||
'--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.',
|
||||
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
|
||||
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
|
||||
'--on selects only the worker server; the Run and this command remain on the current Orca server.',
|
||||
|
||||
@@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' }))
|
||||
})
|
||||
|
||||
await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' })
|
||||
server.retirePaneAuthority(PANE)
|
||||
|
||||
// Why: `claude --resume` in the reused shell pane emits only SessionStart while idle;
|
||||
// without the un-retire the resumed session stays rowless until a prompt (STA-3386).
|
||||
await postHook({ hook_event_name: 'SessionStart', source: 'resume' })
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a live remote prompt but not replay after launch authority retires', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
|
||||
@@ -415,7 +415,10 @@ function equivalentParsedAgentStatusPayload(
|
||||
a.toolInput === b.toolInput &&
|
||||
a.interactivePrompt === b.interactivePrompt &&
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
a.interrupted === b.interrupted
|
||||
a.interrupted === b.interrupted &&
|
||||
// Why: a session-boundary done must never be deduped against a cached real done —
|
||||
// the flag has to reach receivers deterministically (STA-3386).
|
||||
a.sessionBoundary === b.sessionBoundary
|
||||
)
|
||||
}
|
||||
|
||||
@@ -996,7 +999,12 @@ export class AgentHookServer {
|
||||
return 'accept'
|
||||
}
|
||||
// Why: command completion retires launch authority but leaves its shell pane reusable.
|
||||
if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) {
|
||||
// A live SessionStart proves a new agent process owns the retired pane just like a
|
||||
// fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386).
|
||||
if (
|
||||
(event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') &&
|
||||
event.isReplay !== true
|
||||
) {
|
||||
this.closedAgentStatusPaneKeys.delete(paneKey)
|
||||
this.closedAgentStatusPaneKeys.delete(ownerPaneKey)
|
||||
return 'restart'
|
||||
|
||||
@@ -378,6 +378,9 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
// Code rejects unknown shapes silently and the agent-hooks pipeline
|
||||
// goes dark.
|
||||
for (const event of [
|
||||
// Why: SessionStart is the only signal a resumed/idle session ever emits;
|
||||
// without it the sidebar row waits for the first prompt (STA-3386).
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'StopFailure',
|
||||
|
||||
@@ -28,6 +28,9 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
|
||||
}
|
||||
|
||||
export const CLAUDE_EVENTS = [
|
||||
// Why: SessionStart is the only event a resumed/idle session emits before the
|
||||
// first prompt; without it the sidebar row can't exist until the user types (STA-3386).
|
||||
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
// Why: OpenClaude skips normal Stop hooks after API/model errors and emits
|
||||
|
||||
@@ -73,6 +73,9 @@ describe('DaemonClient', () => {
|
||||
pid: number
|
||||
startedAtMs: number
|
||||
launchNonce: string
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
}
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -155,7 +158,14 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
it('captures one matching endpoint identity from both authenticated sockets', async () => {
|
||||
const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' }
|
||||
const identity = {
|
||||
pid: 123,
|
||||
startedAtMs: 456,
|
||||
launchNonce: 'launch-a',
|
||||
entryPath: '/Applications/Orca.app/Contents/Resources/daemon-entry.js',
|
||||
appVersion: '1.2.3',
|
||||
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
}
|
||||
await startMockDaemon({ helloIdentity: () => identity })
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
DaemonEvent
|
||||
} from './types'
|
||||
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
|
||||
import { decodeDaemonResponseError } from './daemon-errors'
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5000
|
||||
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
|
||||
@@ -429,7 +430,12 @@ export class DaemonClient {
|
||||
if (response.ok) {
|
||||
pending.resolve(response.payload)
|
||||
} else {
|
||||
pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error)))
|
||||
const decoded = decodeDaemonResponseError(response.error)
|
||||
pending.reject(
|
||||
decoded instanceof DaemonProtocolError
|
||||
? new DaemonProtocolError(addNodePtyRecoveryHint(response.error))
|
||||
: decoded
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,7 +509,14 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown }
|
||||
const identity = value as {
|
||||
pid?: unknown
|
||||
startedAtMs?: unknown
|
||||
launchNonce?: unknown
|
||||
entryPath?: unknown
|
||||
appVersion?: unknown
|
||||
spawnerExecPath?: unknown
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(identity.pid) ||
|
||||
(identity.pid as number) <= 0 ||
|
||||
@@ -518,7 +531,16 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n
|
||||
return {
|
||||
pid: identity.pid as number,
|
||||
startedAtMs: identity.startedAtMs,
|
||||
launchNonce: identity.launchNonce
|
||||
launchNonce: identity.launchNonce,
|
||||
...(typeof identity.entryPath === 'string' && identity.entryPath.length > 0
|
||||
? { entryPath: identity.entryPath }
|
||||
: {}),
|
||||
...(typeof identity.appVersion === 'string' && identity.appVersion.length > 0
|
||||
? { appVersion: identity.appVersion }
|
||||
: {}),
|
||||
...(typeof identity.spawnerExecPath === 'string' && identity.spawnerExecPath.length > 0
|
||||
? { spawnerExecPath: identity.spawnerExecPath }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { connect, createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath, publishDaemonPidFile } from './daemon-spawner'
|
||||
import { readDaemonSocketIdentity } from './daemon-endpoint-ownership'
|
||||
import type { SubprocessHandle } from './session'
|
||||
|
||||
function connectsTo(socketPath: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
socket.on('connect', () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
socket.on('error', () => resolve(false))
|
||||
})
|
||||
}
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: () => null,
|
||||
write() {},
|
||||
resize() {},
|
||||
kill() {},
|
||||
forceKill() {},
|
||||
signal() {},
|
||||
onData() {},
|
||||
onExit() {},
|
||||
dispose() {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('daemon endpoint ownership publication', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-endpoint-ownership-'))
|
||||
socketPath = getDaemonSocketPath(dir)
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not publish an adoptable endpoint before durable ownership succeeds', async () => {
|
||||
writeFileSync(tokenPath, 'previous-token')
|
||||
const publishEndpointOwnership = vi.fn(() => {
|
||||
expect(readFileSync(tokenPath, 'utf8')).toBe('previous-token')
|
||||
throw Object.assign(new Error('PID record already owned'), {
|
||||
code: 'EEXIST'
|
||||
})
|
||||
})
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
publishEndpointOwnership,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
|
||||
await expect(server.start()).rejects.toThrow('PID record already owned')
|
||||
|
||||
expect(publishEndpointOwnership).toHaveBeenCalledOnce()
|
||||
expect(readFileSync(tokenPath, 'utf8')).toBe('previous-token')
|
||||
if (process.platform !== 'win32') {
|
||||
expect(existsSync(socketPath)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back exact PID ownership when token publication fails', async () => {
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
const launchNonce = 'failed-launch'
|
||||
mkdirSync(tokenPath)
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce,
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: process.pid,
|
||||
startedAtMs: 1_000,
|
||||
launchNonce
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
|
||||
await expect(server.start()).rejects.toMatchObject({ code: 'EISDIR' })
|
||||
|
||||
expect(existsSync(pidPath)).toBe(false)
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps a replacement endpoint when the daemon it replaced closes late',
|
||||
async () => {
|
||||
// Why: this is the split-brain mechanism. libuv unlinks the pathname a server bound to
|
||||
// when that server closes, with no ownership check — so a daemon exiting late used to
|
||||
// delete whichever socket then sat at the canonical path. The replacement stayed alive
|
||||
// hosting PTYs that no client could reach, which is what "terminals ack but never run"
|
||||
// looked like from the user's seat.
|
||||
const replacedPidPath = join(dir, 'replaced.pid')
|
||||
const replaced = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath: replacedPidPath,
|
||||
launchNonce: 'replaced-daemon',
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(replacedPidPath, {
|
||||
pid: process.pid,
|
||||
startedAtMs: 1_000,
|
||||
launchNonce: 'replaced-daemon'
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await replaced.start()
|
||||
|
||||
// A replacement reclaims the endpoint the way killStaleDaemon does.
|
||||
unlinkSync(socketPath)
|
||||
const pidPath = join(dir, 'replacement.pid')
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce: 'replacement-daemon',
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: process.pid,
|
||||
startedAtMs: 2_000,
|
||||
launchNonce: 'replacement-daemon'
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
const replacementIdentity = readDaemonSocketIdentity(socketPath)
|
||||
|
||||
// The daemon that lost the endpoint now exits, long after the handover.
|
||||
await replaced.shutdown()
|
||||
|
||||
expect(existsSync(socketPath)).toBe(true)
|
||||
expect(readDaemonSocketIdentity(socketPath)).toEqual(replacementIdentity)
|
||||
// The replacement is still reachable through the canonical name.
|
||||
await expect(connectsTo(socketPath)).resolves.toBe(true)
|
||||
// The late exit also must not remove the replacement's ownership record.
|
||||
expect(existsSync(pidPath)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'retires a daemon whose endpoint was taken over by another daemon',
|
||||
async () => {
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce: 'original-daemon',
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: process.pid,
|
||||
startedAtMs: 1_000,
|
||||
launchNonce: 'original-daemon'
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
const daemon = server as unknown as {
|
||||
checkEndpointOwnership: () => void
|
||||
retirementRequested: boolean
|
||||
}
|
||||
// An inconclusive or matching probe must never retire a healthy daemon, however often it runs.
|
||||
daemon.checkEndpointOwnership()
|
||||
daemon.checkEndpointOwnership()
|
||||
expect(daemon.retirementRequested).toBe(false)
|
||||
|
||||
// Another daemon takes the endpoint name.
|
||||
unlinkSync(socketPath)
|
||||
const usurper = createServer()
|
||||
const usurperBind = join(dir, '.usurper')
|
||||
await new Promise<void>((resolve) => usurper.listen(usurperBind, resolve))
|
||||
linkSync(usurperBind, socketPath)
|
||||
unlinkSync(usurperBind)
|
||||
|
||||
try {
|
||||
// A single observation can land inside a replacement's unlink-then-link gap, so the
|
||||
// first one must not retire anything.
|
||||
daemon.checkEndpointOwnership()
|
||||
expect(daemon.retirementRequested).toBe(false)
|
||||
|
||||
daemon.checkEndpointOwnership()
|
||||
// Why: retirement drains rather than kills — an orphaned daemon stops being a
|
||||
// permanent unreachable host without tearing live sessions out from under the user.
|
||||
expect(daemon.retirementRequested).toBe(true)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => usurper.close(() => resolve()))
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses a second listener when a live daemon socket path was removed',
|
||||
async () => {
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce: 'first-launch',
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: process.pid,
|
||||
startedAtMs: 1_000,
|
||||
launchNonce: 'first-launch'
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
const firstToken = readFileSync(tokenPath, 'utf8')
|
||||
unlinkSync(socketPath)
|
||||
|
||||
const duplicate = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce: 'second-launch',
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: process.pid,
|
||||
startedAtMs: 2_000,
|
||||
launchNonce: 'second-launch'
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
try {
|
||||
await expect(duplicate.start()).rejects.toMatchObject({
|
||||
code: 'EEXIST'
|
||||
})
|
||||
expect(readFileSync(tokenPath, 'utf8')).toBe(firstToken)
|
||||
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({
|
||||
startedAtMs: 1_000,
|
||||
launchNonce: 'first-launch'
|
||||
})
|
||||
} finally {
|
||||
await duplicate.shutdown()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
/* Ownership of the daemon's canonical endpoint name and of the scratch entries the
|
||||
rename-claim protocol leaves behind. Kept apart from daemon-spawner so the rule that
|
||||
decides who may serve on the socket path is readable on its own. */
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { existsSync, linkSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
/** The exact directory entry a daemon owns. Compared before any endpoint removal. */
|
||||
export type DaemonSocketIdentity = { dev: bigint; ino: bigint }
|
||||
|
||||
/**
|
||||
* A private, same-directory name to bind before publishing the canonical endpoint.
|
||||
*
|
||||
* Why: `sockaddr_un.sun_path` caps a Unix socket path at ~104 bytes, so this must not extend
|
||||
* the canonical path — it replaces the basename with a shorter one, which keeps the bind name
|
||||
* strictly shorter than the endpoint the caller already requires to fit.
|
||||
*/
|
||||
export function getDaemonSocketBindPath(socketPath: string): string {
|
||||
return join(dirname(socketPath), `.b${randomBytes(5).toString('hex')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a bound listener under the canonical endpoint name.
|
||||
*
|
||||
* Why: Node/libuv unlinks the pathname a server bound to when that server closes,
|
||||
* with no ownership check — a daemon exiting late therefore deletes whichever socket
|
||||
* currently sits at that path, including a live replacement's. Binding a unique path
|
||||
* and hard-linking it into place instead means libuv only ever unlinks the private
|
||||
* bind name, and the exclusive link doubles as the kernel-enforced endpoint claim.
|
||||
*/
|
||||
export function publishDaemonSocketPath(
|
||||
boundPath: string,
|
||||
canonicalPath: string
|
||||
): DaemonSocketIdentity | null {
|
||||
if (process.platform === 'win32') {
|
||||
// Named pipes are not directory entries; the pipe name itself is exclusive.
|
||||
return null
|
||||
}
|
||||
// Why: stat the bound name first — the link shares the inode, so a racing unlink of the
|
||||
// canonical name cannot erase our identity and leave the endpoint unwatched and uncleanable.
|
||||
const identity = readDaemonSocketIdentity(boundPath)
|
||||
try {
|
||||
linkSync(boundPath, canonicalPath)
|
||||
} catch (error) {
|
||||
if (isFileExistsError(error)) {
|
||||
throw error
|
||||
}
|
||||
// Why: a filesystem without hard links must not stop the daemon from starting. Rename
|
||||
// still moves the bind name out from under libuv, which preserves the property that
|
||||
// matters most — a late close cannot delete a replacement's endpoint. Exclusivity
|
||||
// degrades to check-then-act here, which is no weaker than binding the path directly.
|
||||
if (existsSync(canonicalPath)) {
|
||||
throw error
|
||||
}
|
||||
renameSync(boundPath, canonicalPath)
|
||||
return identity
|
||||
}
|
||||
try {
|
||||
unlinkSync(boundPath)
|
||||
} catch {
|
||||
// Inert: clients resolve the canonical link, and the bind name is unique to us.
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
function isFileExistsError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST'
|
||||
}
|
||||
|
||||
export function readDaemonSocketIdentity(socketPath: string): DaemonSocketIdentity | null {
|
||||
if (process.platform === 'win32') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const stats = statSync(socketPath, { bigint: true })
|
||||
return { dev: stats.dev, ino: stats.ino }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function daemonSocketIdentityMatches(
|
||||
a: DaemonSocketIdentity | null,
|
||||
b: DaemonSocketIdentity | null
|
||||
): boolean {
|
||||
return a !== null && b !== null && a.dev === b.dev && a.ino === b.ino
|
||||
}
|
||||
|
||||
/** 'indeterminate' is deliberately distinct from 'lost': only positive evidence may retire a daemon. */
|
||||
export type DaemonEndpointOwnershipState = 'owned' | 'lost' | 'indeterminate'
|
||||
|
||||
export function readDaemonEndpointOwnershipState(
|
||||
socketPath: string,
|
||||
owned: DaemonSocketIdentity | null
|
||||
): DaemonEndpointOwnershipState {
|
||||
if (process.platform === 'win32' || !owned) {
|
||||
return 'indeterminate'
|
||||
}
|
||||
try {
|
||||
const stats = statSync(socketPath, { bigint: true })
|
||||
return stats.dev === owned.dev && stats.ino === owned.ino ? 'owned' : 'lost'
|
||||
} catch (error) {
|
||||
// Why: a stat that failed for any reason other than "the entry is gone" proves nothing.
|
||||
// Treating EACCES or EIO as lost ownership would retire a perfectly healthy daemon.
|
||||
return isMissingFileError(error) ? 'lost' : 'indeterminate'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Removes the canonical endpoint name only while it still resolves to our own listener. */
|
||||
export function unlinkOwnedDaemonSocketPath(
|
||||
socketPath: string,
|
||||
owned: DaemonSocketIdentity | null
|
||||
): boolean {
|
||||
if (process.platform === 'win32' || !owned) {
|
||||
return false
|
||||
}
|
||||
if (!daemonSocketIdentityMatches(readDaemonSocketIdentity(socketPath), owned)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const ABANDONED_DAEMON_CLAIM_PATTERN =
|
||||
/(?:\.(?:cleanup|replace)-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|^\.b[0-9a-f]{10})$/
|
||||
|
||||
const ABANDONED_DAEMON_CLAIM_MIN_AGE_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Reclaims claim/bind scratch names left behind when a rename-claim or bind publish
|
||||
* could not remove its own temporary entry. Age-gated so a claim in flight is never touched.
|
||||
*/
|
||||
export function sweepAbandonedDaemonClaims(
|
||||
runtimeDir: string,
|
||||
minAgeMs = ABANDONED_DAEMON_CLAIM_MIN_AGE_MS,
|
||||
now = Date.now()
|
||||
): number {
|
||||
let swept = 0
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(runtimeDir)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!ABANDONED_DAEMON_CLAIM_PATTERN.test(entry)) {
|
||||
continue
|
||||
}
|
||||
const claimPath = join(runtimeDir, entry)
|
||||
try {
|
||||
if (now - statSync(claimPath).mtimeMs < minAgeMs) {
|
||||
continue
|
||||
}
|
||||
unlinkSync(claimPath)
|
||||
swept++
|
||||
} catch {
|
||||
// Best-effort; a locked or already-removed claim is retried on a future launch.
|
||||
}
|
||||
}
|
||||
return swept
|
||||
}
|
||||
@@ -71,6 +71,33 @@ describe('daemon-entry parseArgs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('parses PID publication metadata from the launcher', () => {
|
||||
expect(
|
||||
parseArgs([
|
||||
'--socket',
|
||||
'/tmp/t.sock',
|
||||
'--token',
|
||||
'/tmp/t.token',
|
||||
'--pid-record',
|
||||
'/tmp/t.pid',
|
||||
'--launch-nonce',
|
||||
'launch-a',
|
||||
'--entry-path',
|
||||
'/app/daemon-entry.js',
|
||||
'--app-version',
|
||||
'1.2.3',
|
||||
'--spawner-exec-path',
|
||||
'/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
])
|
||||
).toMatchObject({
|
||||
pidPath: '/tmp/t.pid',
|
||||
launchNonce: 'launch-a',
|
||||
entryPath: '/app/daemon-entry.js',
|
||||
appVersion: '1.2.3',
|
||||
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects either PID-record ownership argument without its pair', () => {
|
||||
expect(() =>
|
||||
parseArgs([
|
||||
|
||||
@@ -20,12 +20,16 @@ import {
|
||||
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
import { publishDaemonPidFile } from './daemon-spawner'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
/** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */
|
||||
loginSessionWatch?: boolean
|
||||
/** Optional — absent for adopted old daemons and tests, which log nothing. */
|
||||
@@ -38,6 +42,9 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
let logFilePath = ''
|
||||
let pidPath = ''
|
||||
let launchNonce = ''
|
||||
let entryPath = ''
|
||||
let appVersion = ''
|
||||
let spawnerExecPath = ''
|
||||
let loginSessionWatch = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -56,6 +63,15 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
|
||||
launchNonce = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--entry-path' && argv[i + 1]) {
|
||||
entryPath = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--app-version' && argv[i + 1]) {
|
||||
appVersion = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--spawner-exec-path' && argv[i + 1]) {
|
||||
spawnerExecPath = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--login-session-watch') {
|
||||
loginSessionWatch = true
|
||||
}
|
||||
@@ -73,6 +89,9 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(pidPath ? { pidPath, launchNonce } : {}),
|
||||
...(entryPath ? { entryPath } : {}),
|
||||
...(appVersion ? { appVersion } : {}),
|
||||
...(spawnerExecPath ? { spawnerExecPath } : {}),
|
||||
...(loginSessionWatch ? { loginSessionWatch } : {}),
|
||||
...(logFilePath ? { logFilePath } : {})
|
||||
}
|
||||
@@ -86,10 +105,19 @@ async function main(): Promise<void> {
|
||||
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
|
||||
process.stderr.on('error', () => {})
|
||||
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs(
|
||||
process.argv.slice(2)
|
||||
)
|
||||
const {
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath,
|
||||
launchNonce,
|
||||
entryPath,
|
||||
appVersion,
|
||||
spawnerExecPath,
|
||||
loginSessionWatch,
|
||||
logFilePath
|
||||
} = parseArgs(process.argv.slice(2))
|
||||
const startedAtMs = Date.now() - process.uptime() * 1000
|
||||
const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs)
|
||||
// Fail-open: a broken log path must never block daemon startup.
|
||||
const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog()
|
||||
daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath })
|
||||
@@ -236,6 +264,22 @@ async function main(): Promise<void> {
|
||||
...(pidPath ? { pidPath } : {}),
|
||||
...(launchNonce ? { launchNonce } : {}),
|
||||
...(pidPath ? { startedAtMs } : {}),
|
||||
...(entryPath ? { entryPath } : {}),
|
||||
...(appVersion ? { appVersion } : {}),
|
||||
...(spawnerExecPath ? { spawnerExecPath } : {}),
|
||||
...(pidPath && launchNonce
|
||||
? {
|
||||
publishEndpointOwnership: () =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: process.pid,
|
||||
...readyIdentity,
|
||||
...(entryPath ? { entryPath } : {}),
|
||||
...(appVersion ? { appVersion } : {}),
|
||||
...(spawnerExecPath ? { spawnerExecPath } : {}),
|
||||
launchNonce
|
||||
})
|
||||
}
|
||||
: {}),
|
||||
log: daemonLog,
|
||||
preparePtySpawn: runMacosLoginPreflight,
|
||||
...(deathWatch
|
||||
@@ -260,13 +304,18 @@ async function main(): Promise<void> {
|
||||
daemonLog.log('shutdown', { reason: 'idle' })
|
||||
daemonLog.close()
|
||||
process.exit(0)
|
||||
},
|
||||
onRpcShutdown: () => {
|
||||
deathWatch?.stop()
|
||||
shuttingDown = true
|
||||
daemonLog.close()
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
deathWatch?.start()
|
||||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs)
|
||||
process.send({ type: 'ready', ...readyIdentity })
|
||||
}
|
||||
daemonLog.log('ready')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DaemonProtocolError,
|
||||
decodeDaemonResponseError,
|
||||
SessionNotFoundError
|
||||
} from './daemon-errors'
|
||||
|
||||
describe('decodeDaemonResponseError', () => {
|
||||
it('types the exact legacy session-absence response', () => {
|
||||
expect(decodeDaemonResponseError('Session not found: pty-1')).toBeInstanceOf(
|
||||
SessionNotFoundError
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps unrelated daemon failures non-authoritative', () => {
|
||||
expect(decodeDaemonResponseError('proxy failed: Session not found: pty-1')).toBeInstanceOf(
|
||||
DaemonProtocolError
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -20,3 +20,17 @@ export class SessionNotFoundError extends Error {
|
||||
this.name = 'SessionNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class TerminalSessionOwnerUnverifiedError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Terminal session owner could not be verified: ${sessionId}`)
|
||||
this.name = 'TerminalSessionOwnerUnverifiedError'
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeDaemonResponseError(message: string): Error {
|
||||
const prefix = 'Session not found: '
|
||||
return message.startsWith(prefix)
|
||||
? new SessionNotFoundError(message.slice(prefix.length))
|
||||
: new DaemonProtocolError(message)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('daemon health socket listener cleanup', () => {
|
||||
const result = killStaleDaemon(dir, socketPath, tokenPath)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
await expect(result).resolves.toEqual({ killed: false, liveOwnerSurvived: false })
|
||||
expect(socket.listenerCount('connect')).toBe(0)
|
||||
expect(socket.listenerCount('error')).toBe(0)
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, linkSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { createServer, connect, type Server } from 'node:net'
|
||||
import { createServer, connect, Socket, type Server } from 'node:net'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner'
|
||||
import {
|
||||
@@ -183,7 +184,9 @@ describe('daemon health', () => {
|
||||
writeFileSync(getDaemonPidPath(dir), String(process.pid), { mode: 0o600 })
|
||||
|
||||
try {
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false)
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toMatchObject({
|
||||
killed: false
|
||||
})
|
||||
await expect(canConnect(socketPath)).resolves.toBe(true)
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
@@ -435,7 +438,9 @@ describe('killStaleDaemon pid identity guards', () => {
|
||||
// signal is sent.
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
try {
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false)
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toMatchObject({
|
||||
killed: false
|
||||
})
|
||||
const terminationSignals = killSpy.mock.calls.filter(
|
||||
([, sig]) => sig === 'SIGTERM' || sig === 'SIGKILL'
|
||||
)
|
||||
@@ -445,3 +450,125 @@ describe('killStaleDaemon pid identity guards', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('killStaleDaemon endpoint reclamation', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-health-kill-test-'))
|
||||
socketPath = daemonTestSocketPath(dir)
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function listenOnSocketPath(server: Server, path: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(path, () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('preserves the pid record and the endpoint when the owner cannot be proven gone', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: isDaemonProcess matches on the command line, so a child carrying the daemon
|
||||
// entry plus this endpoint's paths is adopted as the recorded owner by the first probe.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['-e', 'setInterval(() => {}, 1000)', 'daemon-entry', socketPath, tokenPath],
|
||||
{ stdio: 'ignore' }
|
||||
)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve)
|
||||
child.once('error', reject)
|
||||
})
|
||||
const childPid = child.pid as number
|
||||
const childExited = new Promise<void>((resolve) => child.once('exit', () => resolve()))
|
||||
const server = createServer((socket) => socket.end())
|
||||
await listenOnSocketPath(server, socketPath)
|
||||
writeFileSync(
|
||||
getDaemonPidPath(dir),
|
||||
serializeDaemonPidFile({ pid: childPid, startedAtMs: null }),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
|
||||
const realKill = process.kill.bind(process)
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
// Why: the recorded owner vanishes mid-wait, so the pre-SIGKILL identity re-probe
|
||||
// fails and only the still-answering endpoint proves a daemon is alive.
|
||||
const identityBreaker = setTimeout(() => realKill(childPid, 'SIGKILL'), 800)
|
||||
try {
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({
|
||||
killed: false,
|
||||
liveOwnerSurvived: true
|
||||
})
|
||||
expect(existsSync(getDaemonPidPath(dir))).toBe(true)
|
||||
expect(existsSync(socketPath)).toBe(true)
|
||||
await expect(canConnect(socketPath)).resolves.toBe(true)
|
||||
} finally {
|
||||
clearTimeout(identityBreaker)
|
||||
killSpy.mockRestore()
|
||||
try {
|
||||
realKill(childPid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
await childExited
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('unlinks a stale endpoint file once connects to it are refused', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: hard-linking the bound inode leaves the endpoint name behind after the
|
||||
// listener closes — exactly the entry a crashed daemon leaves for connect to refuse.
|
||||
const bindPath = join(dir, '.bstale')
|
||||
const server = createServer((socket) => socket.end())
|
||||
await listenOnSocketPath(server, bindPath)
|
||||
linkSync(bindPath, socketPath)
|
||||
await closeServer(server)
|
||||
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({
|
||||
killed: false,
|
||||
liveOwnerSurvived: false
|
||||
})
|
||||
expect(existsSync(socketPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the endpoint file when the connect probe neither connects nor is refused', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
writeFileSync(socketPath, '')
|
||||
// A connect that never settles leaves the probe to time out, which is not proof
|
||||
// the endpoint is dead — net.connect itself cannot be spied on in ESM.
|
||||
const connectSpy = vi
|
||||
.spyOn(Socket.prototype, 'connect')
|
||||
.mockImplementation(function (this: Socket) {
|
||||
return this
|
||||
})
|
||||
try {
|
||||
await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({
|
||||
killed: false,
|
||||
liveOwnerSurvived: false
|
||||
})
|
||||
expect(existsSync(socketPath)).toBe(true)
|
||||
} finally {
|
||||
connectSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@ const HEALTH_CHECK_TIMEOUT_MS = 3_000
|
||||
const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000
|
||||
const KILL_WAIT_MS = 3_000
|
||||
const KILL_POLL_MS = 100
|
||||
// Why: SIGKILL is delivered on return from an uninterruptible syscall, so confirm the exit
|
||||
// rather than assume it — but keep the wait short, it only guards the rare wedged case.
|
||||
const SIGKILL_CONFIRM_WAIT_MS = 1_000
|
||||
const START_TIME_TOLERANCE_MS = 1_500
|
||||
// Why: e2e forces the failed-health preserve path without SIGSTOP races —
|
||||
// a stopped daemon also blocks listSessions, so the unhealthy guard cannot
|
||||
@@ -51,10 +54,17 @@ export type ParsedDaemonPid = {
|
||||
spawnerExecPath: string | null
|
||||
}
|
||||
|
||||
function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
/**
|
||||
* 'connected' — something is listening. 'missing'/'refused' — nothing is, and the endpoint
|
||||
* name is safe to reclaim. 'unknown' — the probe itself failed (timeout on a loaded host,
|
||||
* EPERM); the endpoint must be left alone because absence of proof is not proof of death.
|
||||
*/
|
||||
type SocketProbeOutcome = 'connected' | 'missing' | 'refused' | 'unknown'
|
||||
|
||||
function probeSocketConnect(socketPath: string): Promise<SocketProbeOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== 'win32' && !existsSync(socketPath)) {
|
||||
resolve(false)
|
||||
resolve('missing')
|
||||
return
|
||||
}
|
||||
const sock = connect({ path: socketPath })
|
||||
@@ -64,7 +74,7 @@ function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
sock.off('connect', onConnect)
|
||||
sock.off('error', onError)
|
||||
}
|
||||
const settle = (result: boolean): void => {
|
||||
const settle = (result: SocketProbeOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
@@ -73,14 +83,16 @@ function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
resolve(result)
|
||||
}
|
||||
const onConnect = (): void => {
|
||||
settle(true)
|
||||
settle('connected')
|
||||
sock.destroy()
|
||||
}
|
||||
const onError = (): void => {
|
||||
settle(false)
|
||||
const onError = (error: NodeJS.ErrnoException): void => {
|
||||
settle(
|
||||
error.code === 'ECONNREFUSED' ? 'refused' : error.code === 'ENOENT' ? 'missing' : 'unknown'
|
||||
)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
settle(false)
|
||||
settle('unknown')
|
||||
sock.destroy()
|
||||
}, 500)
|
||||
sock.on('connect', onConnect)
|
||||
@@ -279,7 +291,12 @@ export function getMacDaemonSystemResolverHealth(
|
||||
}
|
||||
// Why: the daemon must report health from inside its own process;
|
||||
// external launchctl bsexec probes can misclassify healthy PTYs.
|
||||
sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' }))
|
||||
sock?.write(
|
||||
encodeNdjson({
|
||||
id: 'resolver-health-1',
|
||||
type: 'systemResolverHealth'
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -370,7 +387,10 @@ function getLinuxProcessStartedAtMs(pid: number): number | null {
|
||||
const startTicks = parseLinuxProcStartTicks(stat)
|
||||
const bootTimeSeconds = parseLinuxBootTimeSeconds(readFileSync('/proc/stat', 'utf8'))
|
||||
const ticksPerSecond = Number(
|
||||
execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1_000 }).trim()
|
||||
execFileSync('getconf', ['CLK_TCK'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 1_000
|
||||
}).trim()
|
||||
)
|
||||
if (
|
||||
!Number.isFinite(startTicks) ||
|
||||
@@ -705,14 +725,45 @@ export async function getMacDaemonTccAttributionHealth(
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function isNoSuchProcessError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'
|
||||
}
|
||||
|
||||
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return false
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, KILL_POLL_MS))
|
||||
}
|
||||
}
|
||||
|
||||
export type StaleDaemonKillOutcome = {
|
||||
/** A daemon was positively confirmed gone. Drives replacement telemetry. */
|
||||
killed: boolean
|
||||
/**
|
||||
* We could not prove the recorded daemon is gone. Its PID record and endpoint are left
|
||||
* intact and the caller must not fork a replacement beside it — that is exactly how two
|
||||
* daemons end up alive with one owning the socket and the other hosting the sessions.
|
||||
*/
|
||||
liveOwnerSurvived: boolean
|
||||
}
|
||||
|
||||
export async function killStaleDaemon(
|
||||
runtimeDir: string,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): Promise<boolean> {
|
||||
): Promise<StaleDaemonKillOutcome> {
|
||||
const pidPath = getDaemonPidPath(runtimeDir, protocolVersion)
|
||||
let killedDaemon = false
|
||||
let liveOwnerSurvived = false
|
||||
try {
|
||||
const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
|
||||
if (
|
||||
@@ -720,7 +771,16 @@ export async function killStaleDaemon(
|
||||
(await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs))
|
||||
) {
|
||||
const { pid, startedAtMs } = parsedPid
|
||||
process.kill(pid, 'SIGTERM')
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch (error) {
|
||||
// Why: ESRCH means it is already gone. Anything else (EPERM from a daemon owned by
|
||||
// another user) means it is alive and we cannot stop it — falling through to the
|
||||
// blanket catch would report "nothing alive" and authorize a duplicate beside it.
|
||||
if (!isNoSuchProcessError(error)) {
|
||||
return { killed: false, liveOwnerSurvived: true }
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + KILL_WAIT_MS
|
||||
let exited = false
|
||||
while (Date.now() < deadline) {
|
||||
@@ -738,37 +798,70 @@ export async function killStaleDaemon(
|
||||
// daemon died during the wait. Without this, we'd SIGKILL an unrelated
|
||||
// process that happens to now own the same pid.
|
||||
if (!(await isDaemonProcess(pid, socketPath, tokenPath, startedAtMs))) {
|
||||
console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled')
|
||||
exited = true
|
||||
killedDaemon = true
|
||||
// Why: a failed identity probe has two causes with opposite correct actions —
|
||||
// the pid really was recycled (daemon dead, reclaim the endpoint), or the probe
|
||||
// itself failed under load (`ps` has a 2s timeout). The endpoint settles it: if
|
||||
// something still answers the socket, a daemon is alive and must be preserved.
|
||||
if ((await probeSocketConnect(socketPath)) === 'connected') {
|
||||
console.warn(
|
||||
'[daemon] Preserving daemon that could not be identified: reason=identity_probe_failed'
|
||||
)
|
||||
liveOwnerSurvived = true
|
||||
} else {
|
||||
console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled')
|
||||
exited = true
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
exited = true
|
||||
} catch {
|
||||
// Already dead
|
||||
exited = true
|
||||
}
|
||||
// Why: issuing SIGKILL is not proof of death — a process blocked in an
|
||||
// uninterruptible syscall only dies once it returns. Claiming the kill without
|
||||
// confirming it is what let the endpoint be reclaimed out from under a daemon
|
||||
// that was still alive and still serving.
|
||||
exited = exited || (await waitForProcessExit(pid, SIGKILL_CONFIRM_WAIT_MS))
|
||||
if (!exited) {
|
||||
console.warn('[daemon] Daemon survived SIGKILL: reason=unconfirmed_exit')
|
||||
}
|
||||
}
|
||||
}
|
||||
killedDaemon = killedDaemon || exited
|
||||
killedDaemon = exited
|
||||
// Why: SIGTERM and SIGKILL both failed to produce an exit, so the daemon is still out
|
||||
// there holding the endpoint. Treat it as the owner rather than racing it.
|
||||
liveOwnerSurvived = liveOwnerSurvived || !exited
|
||||
}
|
||||
} catch {
|
||||
// PID file missing or process already dead
|
||||
}
|
||||
|
||||
if (liveOwnerSurvived) {
|
||||
// Why: removing the record of a daemon we failed to kill is what let a replacement
|
||||
// publish its own ownership beside it. Leaving it intact keeps the exclusive PID claim
|
||||
// held, so a duplicate cannot take the endpoint.
|
||||
return { killed: killedDaemon, liveOwnerSurvived }
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(pidPath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
const socketIsLive = await canConnectSocket(socketPath)
|
||||
if (process.platform !== 'win32' && existsSync(socketPath) && (killedDaemon || !socketIsLive)) {
|
||||
const socketOutcome = await probeSocketConnect(socketPath)
|
||||
// Why: only positive evidence of a dead endpoint authorizes reclaiming the name. A probe
|
||||
// that merely timed out leaves a live daemon's endpoint in place instead of unlinking it
|
||||
// and forking a duplicate onto the freed path.
|
||||
const endpointIsReclaimable =
|
||||
killedDaemon || socketOutcome === 'refused' || socketOutcome === 'missing'
|
||||
if (process.platform !== 'win32' && existsSync(socketPath) && endpointIsReclaimable) {
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
return killedDaemon
|
||||
return { killed: killedDaemon, liveOwnerSurvived }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export type DaemonEndpointIdentity = {
|
||||
pid: number
|
||||
startedAtMs: number
|
||||
launchNonce: string
|
||||
/** Optional launch metadata. Absent from daemons that predate it; readers must fall back. */
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
}
|
||||
|
||||
export type HelloResponse = {
|
||||
|
||||
@@ -30,7 +30,11 @@ const {
|
||||
killStaleDaemonMock,
|
||||
getProcessStartedAtMsMock,
|
||||
parseDaemonPidFileMock,
|
||||
replaceDaemonPidFileMock,
|
||||
getDaemonCommandLineMock,
|
||||
unlinkOwnedDaemonPidFileMock,
|
||||
launchedStartedAtMs,
|
||||
readLaunchedDaemonIdentity,
|
||||
daemonClientMock,
|
||||
spawnerInstances,
|
||||
ensureRunningOverrides,
|
||||
@@ -87,16 +91,37 @@ const {
|
||||
const getMacDaemonTccAttributionHealthMock = vi.fn(async () => 'unknown')
|
||||
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
||||
const isDaemonStaleForCurrentBundleMock = vi.fn(() => false)
|
||||
const killStaleDaemonMock = vi.fn(async () => true)
|
||||
const killStaleDaemonMock = vi.fn(async () => ({
|
||||
killed: true,
|
||||
liveOwnerSurvived: false
|
||||
}))
|
||||
const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000)
|
||||
const parseDaemonPidFileMock = vi.fn(
|
||||
(): { pid: number; startedAtMs: number | null } | null => null
|
||||
)
|
||||
const replaceDaemonPidFileMock = vi.fn(() => true)
|
||||
const getDaemonCommandLineMock = vi.fn(async (_pid: number): Promise<string | null> => null)
|
||||
const unlinkOwnedDaemonPidFileMock = vi.fn(() => true)
|
||||
const launchedStartedAtMs = { current: 1_000_000 }
|
||||
|
||||
const readLaunchedDaemonIdentity = () => {
|
||||
const args = forkMock.mock.calls.at(-1)?.[1]
|
||||
const child = forkMock.mock.results.at(-1)?.value as { pid?: unknown } | undefined
|
||||
const launchNonceIndex = Array.isArray(args) ? args.indexOf('--launch-nonce') : -1
|
||||
const launchNonce = launchNonceIndex >= 0 ? args[launchNonceIndex + 1] : undefined
|
||||
return typeof child?.pid === 'number' && typeof launchNonce === 'string'
|
||||
? {
|
||||
pid: child.pid,
|
||||
startedAtMs: launchedStartedAtMs.current,
|
||||
launchNonce
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
||||
request: vi.fn(async () => ({ sessions: [] })),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
@@ -173,7 +198,11 @@ const {
|
||||
killStaleDaemonMock,
|
||||
getProcessStartedAtMsMock,
|
||||
parseDaemonPidFileMock,
|
||||
replaceDaemonPidFileMock,
|
||||
getDaemonCommandLineMock,
|
||||
unlinkOwnedDaemonPidFileMock,
|
||||
launchedStartedAtMs,
|
||||
readLaunchedDaemonIdentity,
|
||||
daemonClientMock,
|
||||
spawnerInstances,
|
||||
ensureRunningOverrides,
|
||||
@@ -253,6 +282,7 @@ vi.mock('net', () => ({ connect: netConnectMock }))
|
||||
|
||||
vi.mock('./daemon-health', () => ({
|
||||
checkDaemonHealth: checkDaemonHealthMock,
|
||||
getDaemonCommandLine: getDaemonCommandLineMock,
|
||||
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
|
||||
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
|
||||
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock,
|
||||
@@ -294,7 +324,10 @@ vi.mock('./daemon-spawner', () => ({
|
||||
const result = await override()
|
||||
const releaseAdoptionLease = vi.fn()
|
||||
adoptionLeaseReleases.push(releaseAdoptionLease)
|
||||
this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) }
|
||||
this.handle = {
|
||||
releaseAdoptionLease,
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
if (result.mode) {
|
||||
this.handle.mode = result.mode
|
||||
}
|
||||
@@ -325,6 +358,8 @@ vi.mock('./daemon-spawner', () => ({
|
||||
getDaemonPidPath: (_dir: string, version?: number) =>
|
||||
`/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`,
|
||||
serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj),
|
||||
replaceDaemonPidFile: replaceDaemonPidFileMock,
|
||||
sweepAbandonedDaemonClaims: vi.fn(() => 0),
|
||||
unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock
|
||||
}))
|
||||
|
||||
@@ -425,7 +460,10 @@ async function importFresh() {
|
||||
// mockReset (not mockClear) also drops an unconsumed *Once queue, so a test that bails early
|
||||
// can't leak a queued false into the next test's confirmedReplacement gate.
|
||||
killStaleDaemonMock.mockReset()
|
||||
killStaleDaemonMock.mockResolvedValue(true)
|
||||
killStaleDaemonMock.mockResolvedValue({
|
||||
killed: true,
|
||||
liveOwnerSurvived: false
|
||||
})
|
||||
getAppPathMock.mockReset()
|
||||
getAppPathMock.mockReturnValue('/fake/app')
|
||||
forkMock.mockReset()
|
||||
@@ -435,6 +473,7 @@ async function importFresh() {
|
||||
daemonClientMock.mockImplementation(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
||||
request: vi.fn(async () => ({ sessions: [] })),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
@@ -450,6 +489,7 @@ async function importFresh() {
|
||||
parseDaemonPidFileMock.mockReturnValue(null)
|
||||
unlinkOwnedDaemonPidFileMock.mockReset()
|
||||
unlinkOwnedDaemonPidFileMock.mockReturnValue(true)
|
||||
launchedStartedAtMs.current = 1_000_000
|
||||
getProcessStartedAtMsMock.mockReset()
|
||||
getProcessStartedAtMsMock.mockReturnValue(1_000_000)
|
||||
// Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer.
|
||||
@@ -492,7 +532,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
probeSocketExistsMock.mockReturnValue(false)
|
||||
netConnectMock.mockReset()
|
||||
netConnectMock.mockImplementation(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
const handlers: Record<string, (() => void)[]> = {
|
||||
connect: [],
|
||||
error: []
|
||||
}
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
@@ -607,7 +650,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(spawnerInstances[0].ensureRunning).toHaveBeenCalledTimes(1)
|
||||
|
||||
abortController.abort()
|
||||
resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' })
|
||||
resolveEnsureRunning({
|
||||
socketPath: '/fake/socket-late',
|
||||
tokenPath: '/fake/token-late'
|
||||
})
|
||||
await started
|
||||
|
||||
expect(adapterInstances).toHaveLength(1)
|
||||
@@ -691,7 +737,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
const result = await provider!.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
expect(result.id).toBe('local-fallback-pty')
|
||||
expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 })
|
||||
expect(localFallbackProvider.spawn).toHaveBeenCalledWith({
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(adapterInstances[0].listProcesses).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -894,7 +943,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
ensureRunningOverrides.push(async () => {
|
||||
await outgoingRespawn?.('daemon_died')
|
||||
respawnedMidRestart = true
|
||||
return { socketPath: '/fake/restarted-socket', tokenPath: '/fake/restarted-token' }
|
||||
return {
|
||||
socketPath: '/fake/restarted-socket',
|
||||
tokenPath: '/fake/restarted-token'
|
||||
}
|
||||
})
|
||||
|
||||
await mod.restartDaemon()
|
||||
@@ -915,7 +967,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
ensureRunningOverrides.push(async () => {
|
||||
await restartedRespawn?.('daemon_died')
|
||||
respawnedMidSecondRestart = true
|
||||
return { socketPath: '/fake/restarted-socket-2', tokenPath: '/fake/restarted-token-2' }
|
||||
return {
|
||||
socketPath: '/fake/restarted-socket-2',
|
||||
tokenPath: '/fake/restarted-token-2'
|
||||
}
|
||||
})
|
||||
|
||||
await mod.restartDaemon()
|
||||
@@ -1066,7 +1121,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
// Make probeSocket return true: needs both the fs.existsSync proxy AND net.connect resolving "alive".
|
||||
probeSocketExistsMock.mockReturnValue(true)
|
||||
netConnectMock.mockImplementationOnce(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
const handlers: Record<string, (() => void)[]> = {
|
||||
connect: [],
|
||||
error: []
|
||||
}
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
@@ -1087,7 +1145,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
// The shutdown RPC must have been issued with killSessions=true.
|
||||
expect(ensureConnectedMock).toHaveBeenCalled()
|
||||
expect(requestMock).toHaveBeenCalledWith('shutdown', { killSessions: true })
|
||||
expect(requestMock).toHaveBeenCalledWith('shutdown', {
|
||||
killSessions: true
|
||||
})
|
||||
// The fallback killStaleDaemon must NOT fire when the RPC path worked.
|
||||
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1125,7 +1185,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
await expect(cleanup).resolves.toEqual({ cleaned: false, killedCount: 0 })
|
||||
await expect(cleanup).resolves.toEqual({
|
||||
cleaned: false,
|
||||
killedCount: 0
|
||||
})
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(socket.listenerCount('connect')).toBe(0)
|
||||
expect(socket.listenerCount('error')).toBe(0)
|
||||
@@ -1188,7 +1251,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
||||
await mod.initDaemonPtyProvider(undefined, {
|
||||
macosLoginSessionWatch: true
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
@@ -1352,6 +1417,194 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('repairs a stale PID record to the authenticated socket owner before adoption', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
const endpointIdentity = {
|
||||
pid: 101,
|
||||
startedAtMs: 1_000_000,
|
||||
launchNonce: 'socket-owner'
|
||||
}
|
||||
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
getDaemonIdentity: vi.fn(() => endpointIdentity),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
readFileSyncMock.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
pid: 202,
|
||||
startedAtMs: 2_000_000,
|
||||
launchNonce: 'stale-owner'
|
||||
})
|
||||
)
|
||||
readFileSyncMock.mockReturnValueOnce(JSON.stringify(endpointIdentity))
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise<void> }>
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const handle = await launcher(
|
||||
'/fake/socket',
|
||||
'/fake/token',
|
||||
'/fake/daemon.pid',
|
||||
'unused-new-launch'
|
||||
)
|
||||
|
||||
expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', endpointIdentity)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[daemon] Repaired daemon PID ownership to match the authenticated endpoint'
|
||||
)
|
||||
handle.releaseAdoptionLease?.()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('republishes the endpoint owner launch metadata into a repaired PID record', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
// Why: the mismatched record's metadata belongs to another daemon, so freshness and
|
||||
// host-pinning fields must come from the authenticated owner — a record without
|
||||
// appVersion reads as a permanently stale bundle and gets needlessly replaced. The
|
||||
// install path deliberately contains a space: re-parsing it out of a space-joined
|
||||
// command line truncated it and made a healthy daemon look like a different app path.
|
||||
const endpointIdentity = {
|
||||
pid: 101,
|
||||
startedAtMs: 1_000_000,
|
||||
launchNonce: 'socket-owner',
|
||||
entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js',
|
||||
appVersion: '9.9.9',
|
||||
spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca'
|
||||
}
|
||||
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
getDaemonIdentity: vi.fn(() => endpointIdentity),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
readFileSyncMock.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
pid: 202,
|
||||
startedAtMs: 2_000_000,
|
||||
launchNonce: 'stale-owner'
|
||||
})
|
||||
)
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise<void> }>
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused')
|
||||
|
||||
expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', {
|
||||
pid: 101,
|
||||
startedAtMs: 1_000_000,
|
||||
launchNonce: 'socket-owner',
|
||||
entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js',
|
||||
appVersion: '9.9.9',
|
||||
spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca'
|
||||
})
|
||||
handle.releaseAdoptionLease?.()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts the authenticated endpoint even when the PID record cannot be repaired', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
const endpointIdentity = {
|
||||
pid: 101,
|
||||
startedAtMs: 1_000_000,
|
||||
launchNonce: 'socket-owner'
|
||||
}
|
||||
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
getDaemonIdentity: vi.fn(() => endpointIdentity),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
readFileSyncMock.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
pid: 202,
|
||||
startedAtMs: 2_000_000,
|
||||
launchNonce: 'stale-owner'
|
||||
})
|
||||
)
|
||||
// Why: fail open. Losing every persistent terminal because a pid file write failed is a
|
||||
// far worse outcome than a record that disagrees with the endpoint.
|
||||
replaceDaemonPidFileMock.mockReturnValueOnce(false)
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise<void> }>
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const forkCallsBefore = forkMock.mock.calls.length
|
||||
|
||||
try {
|
||||
const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused')
|
||||
|
||||
expect(handle).toBeDefined()
|
||||
// The healthy daemon is adopted, not replaced.
|
||||
expect(forkMock.mock.calls.length).toBe(forkCallsBefore)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway'
|
||||
)
|
||||
handle.releaseAdoptionLease?.()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('degrades instead of forking beside a daemon that could not be confirmed stopped', async () => {
|
||||
const mod = await importFresh()
|
||||
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
||||
await mod.initDaemonPtyProvider()
|
||||
const forkCallsBefore = forkMock.mock.calls.length
|
||||
// Why: forking beside a survivor is exactly how the endpoint owner and the session host
|
||||
// diverge — one daemon answers the socket while another hosts the visible terminals. But
|
||||
// refusing outright would leave the user with no daemon at all, and something demonstrably
|
||||
// still answers the endpoint, so adopt it degraded: live sessions keep working.
|
||||
killStaleDaemonMock.mockResolvedValueOnce({
|
||||
killed: false,
|
||||
liveOwnerSurvived: true
|
||||
})
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
) => Promise<{ mode?: string; releaseAdoptionLease?(): void }>
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new')
|
||||
|
||||
expect(handle.mode).toBe('degraded-new-pty-fallback')
|
||||
expect(forkMock.mock.calls.length).toBe(forkCallsBefore)
|
||||
handle.releaseAdoptionLease?.()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('disconnects every temporary client when healthy adoption fails', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
@@ -1663,7 +1916,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
||||
killStaleDaemonMock.mockResolvedValueOnce(false)
|
||||
killStaleDaemonMock.mockResolvedValueOnce({
|
||||
killed: false,
|
||||
liveOwnerSurvived: false
|
||||
})
|
||||
forkMock.mockImplementationOnce(() => {
|
||||
throw new Error('stop after replacement decision')
|
||||
})
|
||||
@@ -1691,7 +1947,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
// The daemon is gone before the launcher looks: nothing answers, nothing left to kill.
|
||||
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
||||
killStaleDaemonMock.mockResolvedValueOnce(false).mockResolvedValueOnce(false)
|
||||
killStaleDaemonMock
|
||||
.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false })
|
||||
.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false })
|
||||
forkMock.mockImplementationOnce(() => {
|
||||
throw new Error('stop after replacement decision')
|
||||
})
|
||||
@@ -1789,12 +2047,18 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
trackDaemonReplacedMock.mockClear()
|
||||
|
||||
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
||||
killStaleDaemonMock.mockResolvedValueOnce(false)
|
||||
killStaleDaemonMock.mockResolvedValueOnce({
|
||||
killed: false,
|
||||
liveOwnerSurvived: false
|
||||
})
|
||||
// The daemon answers cleanup's liveness probe, then the endpoint goes away so the self-shutdown
|
||||
// wait succeeds and cleanup reports cleaned:true.
|
||||
probeSocketExistsMock.mockReturnValue(true)
|
||||
netConnectMock.mockImplementationOnce(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
const handlers: Record<string, (() => void)[]> = {
|
||||
connect: [],
|
||||
error: []
|
||||
}
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
@@ -1883,29 +2147,115 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(handlers.exit).toHaveLength(0)
|
||||
expect(child.disconnect).toHaveBeenCalledOnce()
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? []
|
||||
expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`)
|
||||
expect(JSON.parse(pidContents as string)).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: 1_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a',
|
||||
entryPath: FAKE_DAEMON_ENTRY_PATH,
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
spawnerExecPath: process.execPath
|
||||
})
|
||||
expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' })
|
||||
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
||||
const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[]
|
||||
const launchNonceIndex = launchArgs.indexOf('--launch-nonce')
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--pid-record',
|
||||
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
|
||||
'--launch-nonce'
|
||||
'--launch-nonce',
|
||||
expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
'--entry-path',
|
||||
FAKE_DAEMON_ENTRY_PATH,
|
||||
'--app-version',
|
||||
'1.2.3',
|
||||
'--spawner-exec-path',
|
||||
process.execPath
|
||||
])
|
||||
)
|
||||
expect(launchArgs[launchNonceIndex + 1]).toBe(JSON.parse(pidContents as string).launchNonce)
|
||||
expect(launchArgs[launchNonceIndex + 1]).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'changed endpoint identity',
|
||||
() => ({
|
||||
getDaemonIdentity: vi.fn(() => ({
|
||||
pid: 999,
|
||||
startedAtMs: 900_000,
|
||||
launchNonce: 'stale-launch'
|
||||
}))
|
||||
})
|
||||
],
|
||||
['missing endpoint identity reader', () => ({})]
|
||||
])('rejects and reaps a child with %s before adoption', async (_case, identityMethods) => {
|
||||
const mod = await importFresh()
|
||||
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
||||
await mod.initDaemonPtyProvider()
|
||||
function basicClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: vi.fn(async () => ({ sessions: [] })),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
}
|
||||
daemonClientMock.mockImplementationOnce(basicClient)
|
||||
daemonClientMock.mockImplementationOnce(basicClient)
|
||||
daemonClientMock.mockImplementationOnce(function postReadyClient() {
|
||||
return {
|
||||
...basicClient(),
|
||||
...identityMethods()
|
||||
}
|
||||
})
|
||||
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
||||
message: [],
|
||||
error: [],
|
||||
exit: []
|
||||
}
|
||||
const child = {
|
||||
pid: 12345,
|
||||
connected: true,
|
||||
exitCode: null as number | null,
|
||||
signalCode: null as NodeJS.Signals | null,
|
||||
on(event: string, callback: (arg?: unknown) => void) {
|
||||
handlers[event]?.push(callback)
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 }))
|
||||
}
|
||||
return this
|
||||
},
|
||||
off(event: string, callback: (arg?: unknown) => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
||||
return this
|
||||
},
|
||||
kill: vi.fn(() => true),
|
||||
disconnect: vi.fn(() => {
|
||||
child.connected = false
|
||||
}),
|
||||
unref: vi.fn()
|
||||
}
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 0
|
||||
for (const callback of handlers.exit.slice()) {
|
||||
callback(0)
|
||||
}
|
||||
})
|
||||
return true
|
||||
})
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
|
||||
try {
|
||||
await expect(
|
||||
launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new')
|
||||
).rejects.toThrow('Daemon endpoint ownership changed during startup')
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM')
|
||||
expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith(
|
||||
'/fake/daemon.pid',
|
||||
12345,
|
||||
'launch-new'
|
||||
)
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => {
|
||||
@@ -1969,11 +2319,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed')
|
||||
).rejects.toThrow('adoption unavailable')
|
||||
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
'/fake/daemon.pid',
|
||||
expect.stringContaining('launch-delayed'),
|
||||
{ mode: 0o600, flag: 'wx' }
|
||||
)
|
||||
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled()
|
||||
expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -2077,6 +2423,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL')
|
||||
expect(child.disconnect).toHaveBeenCalledOnce()
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
vi.useRealTimers()
|
||||
@@ -2084,7 +2431,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
})
|
||||
|
||||
it('surfaces non-ESRCH startup termination errors and releases IPC', async () => {
|
||||
const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' })
|
||||
const signalError = Object.assign(new Error('operation not permitted'), {
|
||||
code: 'EPERM'
|
||||
})
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw signalError
|
||||
})
|
||||
@@ -2119,7 +2468,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toEqual([
|
||||
expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
|
||||
expect.objectContaining({
|
||||
message: 'Daemon readiness identity is incomplete'
|
||||
}),
|
||||
signalError
|
||||
])
|
||||
expect(child.disconnect).toHaveBeenCalledOnce()
|
||||
@@ -2174,8 +2525,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toEqual([
|
||||
expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
|
||||
expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' })
|
||||
expect.objectContaining({
|
||||
message: 'Daemon readiness identity is incomplete'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message: 'Daemon did not exit after SIGKILL'
|
||||
})
|
||||
])
|
||||
expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM')
|
||||
expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL')
|
||||
@@ -2190,7 +2545,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('kills and rejects a daemon when exclusive PID publication fails', async () => {
|
||||
it('leaves exclusive PID publication to the daemon child', async () => {
|
||||
const mod = await importFresh()
|
||||
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
||||
await mod.initDaemonPtyProvider()
|
||||
@@ -2199,9 +2554,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('already exited'), { code: 'ESRCH' })
|
||||
})
|
||||
const child = {
|
||||
pid: 12345,
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
@@ -2214,27 +2566,23 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
disconnect: vi.fn(),
|
||||
unref: vi.fn()
|
||||
}
|
||||
const publicationError = Object.assign(new Error('PID record already exists'), {
|
||||
code: 'EEXIST'
|
||||
})
|
||||
writeFileSyncMock.mockImplementationOnce(() => {
|
||||
throw publicationError
|
||||
})
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
|
||||
try {
|
||||
await expect(launcher('/fake/socket', '/fake/token')).rejects.toBe(publicationError)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(forkMock.mock.calls.at(-1)?.[1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--pid-record',
|
||||
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
|
||||
expect.any(String),
|
||||
{ mode: 0o600, flag: 'wx' }
|
||||
)
|
||||
expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM')
|
||||
expect(child.disconnect).not.toHaveBeenCalled()
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
'--entry-path',
|
||||
FAKE_DAEMON_ENTRY_PATH,
|
||||
'--app-version',
|
||||
'1.2.3',
|
||||
'--spawner-exec-path',
|
||||
process.execPath
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('removes detached daemon startup listeners after startup error', async () => {
|
||||
@@ -2289,7 +2637,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
tokenPath: string,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
||||
message: [],
|
||||
@@ -2343,11 +2693,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
}
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
|
||||
const error = await launcher('/fake/socket', '/fake/token').catch((err: Error) => err)
|
||||
const error = await launcher(
|
||||
'/fake/socket',
|
||||
'/fake/token',
|
||||
'/fake/daemon.pid',
|
||||
'failed-launch'
|
||||
).catch((err: Error) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toMatch(/Cannot find module 'electron'/)
|
||||
expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/)
|
||||
expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith(
|
||||
'/fake/daemon.pid',
|
||||
4321,
|
||||
'failed-launch'
|
||||
)
|
||||
// Why: release the piped stderr so the detached daemon can't keep the parent event loop alive after failure.
|
||||
expect(stderrDestroy).toHaveBeenCalled()
|
||||
})
|
||||
@@ -2463,7 +2823,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ mode?: 'degraded-new-pty-fallback'; shutdown(): Promise<void> }>
|
||||
) => Promise<{
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
shutdown(): Promise<void>
|
||||
}>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
||||
|
||||
const handle = await launcher('/fake/socket', '/fake/token')
|
||||
@@ -2616,7 +2979,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: vi.fn(async () => ({ sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] })),
|
||||
request: vi.fn(async () => ({
|
||||
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
|
||||
})),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
@@ -2657,6 +3022,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
throw new Error('Hello response timed out')
|
||||
}
|
||||
}),
|
||||
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
@@ -2834,8 +3200,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => {
|
||||
// Why: getProcessStartedAtMs has no cheap Windows impl, so the pid-recycling guard uses the ready-message fallback.
|
||||
it('accepts the daemon self-reported start time when the OS query returns null', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
@@ -2845,6 +3210,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
||||
getProcessStartedAtMsMock.mockReturnValue(null)
|
||||
launchedStartedAtMs.current = 1_700_000_123_456
|
||||
forkMock.mockImplementationOnce(() => ({
|
||||
pid: 12345,
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
@@ -2862,17 +3228,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? []
|
||||
expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`)
|
||||
expect(JSON.parse(pidContents as string)).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: 1_700_000_123_456,
|
||||
entryPath: FAKE_DAEMON_ENTRY_PATH,
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
spawnerExecPath: process.execPath
|
||||
})
|
||||
expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' })
|
||||
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => {
|
||||
@@ -2880,7 +3236,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
const mod = await importFresh()
|
||||
readFileSyncMock.mockReturnValue('{"pid":123}')
|
||||
// process.pid is guaranteed alive, so the liveness probe succeeds.
|
||||
parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null })
|
||||
parseDaemonPidFileMock.mockReturnValue({
|
||||
pid: process.pid,
|
||||
startedAtMs: null
|
||||
})
|
||||
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
|
||||
+187
-33
@@ -3,7 +3,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart +
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app } from 'electron'
|
||||
import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import {
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
getDaemonPidPath,
|
||||
getDaemonSocketPath,
|
||||
getDaemonTokenPath,
|
||||
serializeDaemonPidFile,
|
||||
replaceDaemonPidFile,
|
||||
unlinkOwnedDaemonPidFile,
|
||||
type DaemonLauncher,
|
||||
type DaemonPidFile,
|
||||
type DaemonProcessHandle
|
||||
} from './daemon-spawner'
|
||||
import { sweepAbandonedDaemonClaims } from './daemon-endpoint-ownership'
|
||||
import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import { DaemonClient } from './client'
|
||||
@@ -55,12 +57,16 @@ import {
|
||||
confirmSeededClaudeLivePtys,
|
||||
hasSeededUnconfirmedClaudePtys
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
|
||||
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
|
||||
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
if (isStartupDiagnosticsEnabled()) {
|
||||
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
|
||||
logStartupDiagnostic(event, {
|
||||
t: Math.round(performance.now()),
|
||||
...details
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +76,24 @@ const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000
|
||||
const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000
|
||||
const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000
|
||||
|
||||
class DaemonEndpointOwnershipError extends Error {}
|
||||
|
||||
/**
|
||||
* The one method the ownership checks need from a connected client.
|
||||
*
|
||||
* Why: naming the capability instead of casting DaemonClient to Partial keeps a rename from
|
||||
* silently turning the fence into a no-op — the compiler now rejects a client without it.
|
||||
*/
|
||||
type DaemonEndpointIdentityReader = {
|
||||
getDaemonIdentity?: () => DaemonEndpointIdentity | null
|
||||
}
|
||||
|
||||
function readDaemonEndpointIdentity(
|
||||
client: DaemonEndpointIdentityReader
|
||||
): DaemonEndpointIdentity | null {
|
||||
return client.getDaemonIdentity?.() ?? null
|
||||
}
|
||||
|
||||
let spawner: DaemonSpawner | null = null
|
||||
type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
|
||||
|
||||
@@ -186,11 +210,25 @@ async function holdDaemonAdoptionLease(
|
||||
handle: DaemonProcessHandle,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
connectedClient?: DaemonClient
|
||||
connectedClient?: DaemonClient,
|
||||
expectedIdentity?: DaemonEndpointIdentity,
|
||||
pidPath?: string
|
||||
): Promise<DaemonProcessHandle> {
|
||||
const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath })
|
||||
try {
|
||||
await client.ensureConnected()
|
||||
if (expectedIdentity) {
|
||||
const actualIdentity = readDaemonEndpointIdentity(client)
|
||||
if (
|
||||
!actualIdentity ||
|
||||
actualIdentity.pid !== expectedIdentity.pid ||
|
||||
actualIdentity.startedAtMs !== expectedIdentity.startedAtMs ||
|
||||
actualIdentity.launchNonce !== expectedIdentity.launchNonce
|
||||
) {
|
||||
throw new DaemonEndpointOwnershipError('Daemon endpoint ownership changed during startup')
|
||||
}
|
||||
}
|
||||
await reconcileDaemonPidOwnership(client, pidPath)
|
||||
} catch (error) {
|
||||
client.disconnect()
|
||||
throw error
|
||||
@@ -199,6 +237,79 @@ async function holdDaemonAdoptionLease(
|
||||
return handle
|
||||
}
|
||||
|
||||
async function reconcileDaemonPidOwnership(
|
||||
client: DaemonEndpointIdentityReader,
|
||||
pidPath?: string
|
||||
): Promise<void> {
|
||||
const endpointIdentity = readDaemonEndpointIdentity(client)
|
||||
if (!pidPath || !endpointIdentity || pidRecordMatchesEndpoint(pidPath, endpointIdentity)) {
|
||||
return
|
||||
}
|
||||
// Why: the mismatched record's metadata describes a different daemon and must not be copied
|
||||
// onto this one. Re-derive it from the authenticated owner instead, so the repaired record
|
||||
// keeps the fields freshness, host pinning and pid-recycle detection depend on.
|
||||
const { pid, startedAtMs, launchNonce } = endpointIdentity
|
||||
const ownerMetadata = await readDaemonOwnerMetadata(endpointIdentity)
|
||||
if (!replaceDaemonPidFile(pidPath, { pid, startedAtMs, launchNonce, ...ownerMetadata })) {
|
||||
// Why: fail open. A record that disagrees with the endpoint is a diagnosable nuisance;
|
||||
// abandoning a healthy adoptable daemon over a failed file write costs the user every
|
||||
// persistent terminal on the machine.
|
||||
console.warn(
|
||||
'[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway'
|
||||
)
|
||||
return
|
||||
}
|
||||
console.warn('[daemon] Repaired daemon PID ownership to match the authenticated endpoint')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers the owning daemon's launch metadata.
|
||||
*
|
||||
* Why: `entryPath`/`appVersion` gate bundle-freshness and (on Windows) which relocated daemon
|
||||
* host directories are pinned against pruning, and the Linux pair gates pid-recycle detection.
|
||||
* Publishing a repaired record without them makes a healthy daemon look permanently stale.
|
||||
* The values come from the authenticated hello rather than the owner's command line: a command
|
||||
* line is a single space-joined string, so any install path containing a space (`C:\Program
|
||||
* Files\...`, `/Applications/Orca 2.app/...`) cannot be split back into argv unambiguously.
|
||||
*/
|
||||
async function readDaemonOwnerMetadata(
|
||||
identity: DaemonEndpointIdentity
|
||||
): Promise<Partial<DaemonPidFile>> {
|
||||
const metadata: Partial<DaemonPidFile> = {}
|
||||
if (identity.entryPath) {
|
||||
metadata.entryPath = identity.entryPath
|
||||
}
|
||||
if (identity.appVersion) {
|
||||
metadata.appVersion = identity.appVersion
|
||||
}
|
||||
if (identity.spawnerExecPath) {
|
||||
metadata.spawnerExecPath = identity.spawnerExecPath
|
||||
}
|
||||
const incarnation = await readDaemonProcessIncarnation(identity.pid)
|
||||
if (incarnation) {
|
||||
metadata.linuxStartTicks = incarnation.linuxStartTicks
|
||||
metadata.bootId = incarnation.bootId
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
function pidRecordMatchesEndpoint(pidPath: string, identity: DaemonEndpointIdentity): boolean {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(pidPath, 'utf8')) as {
|
||||
pid?: unknown
|
||||
startedAtMs?: unknown
|
||||
launchNonce?: unknown
|
||||
}
|
||||
return (
|
||||
record.pid === identity.pid &&
|
||||
record.startedAtMs === identity.startedAtMs &&
|
||||
record.launchNonce === identity.launchNonce
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void {
|
||||
takeDaemonAdoptionLeaseRelease(handle)?.()
|
||||
}
|
||||
@@ -352,10 +463,14 @@ function createOutOfProcessLauncher(
|
||||
}
|
||||
| undefined
|
||||
let confirmedReplacement = false
|
||||
let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath })
|
||||
let adoptionClient: DaemonClient | null = new DaemonClient({
|
||||
socketPath,
|
||||
tokenPath
|
||||
})
|
||||
try {
|
||||
// Why: acquire the full pair before control-only probes so an expired inherited deadline can't fire in the probe-to-adoption gap.
|
||||
await adoptionClient.ensureConnected()
|
||||
await reconcileDaemonPidOwnership(adoptionClient, pidPath)
|
||||
} catch {
|
||||
adoptionClient.disconnect()
|
||||
adoptionClient = null
|
||||
@@ -369,7 +484,9 @@ function createOutOfProcessLauncher(
|
||||
createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode),
|
||||
socketPath,
|
||||
tokenPath,
|
||||
connectedClient
|
||||
connectedClient,
|
||||
undefined,
|
||||
pidPath
|
||||
)
|
||||
}
|
||||
try {
|
||||
@@ -387,7 +504,10 @@ function createOutOfProcessLauncher(
|
||||
return preserveDaemon()
|
||||
}
|
||||
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
|
||||
pendingReplacement = { reason: 'unhealthy_resolver', liveSessionCount }
|
||||
pendingReplacement = {
|
||||
reason: 'unhealthy_resolver',
|
||||
liveSessionCount
|
||||
}
|
||||
confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION))
|
||||
.cleaned
|
||||
} else {
|
||||
@@ -495,14 +615,34 @@ function createOutOfProcessLauncher(
|
||||
}
|
||||
// Why: unlike the log above, telemetry gates on confirmedReplacement below — the
|
||||
// post-kill truth — so a cold start that killed nothing never reports a replacement.
|
||||
pendingReplacement = { reason: 'failed_health_check', liveSessionCount }
|
||||
pendingReplacement = {
|
||||
reason: 'failed_health_check',
|
||||
liveSessionCount
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one.
|
||||
adoptionClient?.disconnect()
|
||||
adoptionClient = null
|
||||
confirmedReplacement =
|
||||
(await killStaleDaemon(runtimeDir, socketPath, tokenPath)) || confirmedReplacement
|
||||
const killOutcome = await killStaleDaemon(runtimeDir, socketPath, tokenPath)
|
||||
if (killOutcome.liveOwnerSurvived) {
|
||||
// Why: forking beside a daemon we could not prove dead is precisely how the endpoint
|
||||
// owner and the session host diverge. But refusing outright would leave the user with
|
||||
// no daemon at all, and we have just proved something still answers the endpoint —
|
||||
// so adopt it in degraded mode: existing sessions keep working, new PTYs run locally.
|
||||
console.warn(
|
||||
'[daemon] DEGRADED MODE: adopting a daemon that could not be confirmed stopped. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).'
|
||||
)
|
||||
try {
|
||||
return await preserveDaemon('degraded-new-pty-fallback')
|
||||
} catch {
|
||||
// It died between the probe and the adoption; the endpoint is genuinely free now.
|
||||
throw new DaemonEndpointOwnershipError(
|
||||
'Daemon replacement aborted: the existing daemon could not be confirmed stopped'
|
||||
)
|
||||
}
|
||||
}
|
||||
confirmedReplacement = killOutcome.killed || confirmedReplacement
|
||||
// Why: rank by how well each reason is evidenced. A confirmed kill whose reason positively
|
||||
// identified the daemon outranks the attribution, so a stale bundle caught here is not billed
|
||||
// to the resolver. failed_health_check is the residual "couldn't tell" bucket though — it also
|
||||
@@ -539,6 +679,12 @@ function createOutOfProcessLauncher(
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
app.getVersion(),
|
||||
'--spawner-exec-path',
|
||||
process.execPath,
|
||||
...(macosLoginSessionWatch ? ['--login-session-watch'] : []),
|
||||
...daemonLogArgs()
|
||||
],
|
||||
@@ -582,6 +728,7 @@ function createOutOfProcessLauncher(
|
||||
}
|
||||
|
||||
// Wait for the daemon to signal readiness via IPC
|
||||
let launchedIdentity: DaemonEndpointIdentity | null = null
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let settled = false
|
||||
@@ -619,6 +766,9 @@ function createOutOfProcessLauncher(
|
||||
)
|
||||
return
|
||||
}
|
||||
if (Number.isSafeInteger(child.pid) && (child.pid as number) > 0) {
|
||||
unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce)
|
||||
}
|
||||
reject(startupError)
|
||||
}
|
||||
function onReadyMessage(msg: unknown): void {
|
||||
@@ -631,25 +781,10 @@ function createOutOfProcessLauncher(
|
||||
void fail(new Error('Daemon readiness identity is incomplete'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: pid record shares the daemon's self time and nonce so cleanup can identify this exact process incarnation.
|
||||
writeFileSync(
|
||||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid as number,
|
||||
...readyIdentity,
|
||||
entryPath,
|
||||
appVersion: app.getVersion(),
|
||||
launchNonce,
|
||||
// Why: macOS pins the daemon's TCC responsible process to this binary;
|
||||
// adoption re-checks it still exists so severed grants are detectable.
|
||||
spawnerExecPath: process.execPath
|
||||
}),
|
||||
{ mode: 0o600, flag: 'wx' }
|
||||
)
|
||||
} catch (error) {
|
||||
void fail(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
launchedIdentity = {
|
||||
pid: child.pid as number,
|
||||
...readyIdentity,
|
||||
launchNonce
|
||||
}
|
||||
settled = true
|
||||
// Why: daemon is detached after readiness; detach startup listeners so the launch promise closure isn't retained.
|
||||
@@ -680,14 +815,25 @@ function createOutOfProcessLauncher(
|
||||
})
|
||||
|
||||
try {
|
||||
if (!launchedIdentity) {
|
||||
throw new Error('Daemon readiness identity is incomplete')
|
||||
}
|
||||
return await holdDaemonAdoptionLease(
|
||||
{
|
||||
shutdown: () => terminateLaunchedDaemonChild(child)
|
||||
},
|
||||
socketPath,
|
||||
tokenPath
|
||||
tokenPath,
|
||||
undefined,
|
||||
launchedIdentity,
|
||||
pidPath
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonEndpointOwnershipError) {
|
||||
await terminateLaunchedDaemonChild(child)
|
||||
unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce)
|
||||
throw error
|
||||
}
|
||||
// Why: another client may have adopted this live process; keep its pid record until exit, but remove one published after an early exit.
|
||||
let pidRecordRemoved = false
|
||||
const removeExitedPidRecord = (): void => {
|
||||
@@ -726,6 +872,11 @@ export async function initDaemonPtyProvider(
|
||||
}
|
||||
const runtimeDir = getRuntimeDir()
|
||||
|
||||
// Why: rename-claim and bind scratch names are unlinked by their owner, but a failed unlink
|
||||
// leaves one behind forever. Sweep before launching so a failed launch is still reclaimed;
|
||||
// age-gated so a claim still in flight is never disturbed.
|
||||
sweepAbandonedDaemonClaims(runtimeDir)
|
||||
|
||||
const newSpawner = new DaemonSpawner({
|
||||
runtimeDir,
|
||||
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
|
||||
@@ -825,7 +976,9 @@ export async function initDaemonPtyProvider(
|
||||
setLocalPtyProvider(routedAdapter)
|
||||
// Why: the first window may register PTY listeners before daemon init finishes; rebind so daemon PTYs still fan out events.
|
||||
rebindLocalProviderListeners()
|
||||
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
|
||||
logDaemonMilestone('daemon-init-done', {
|
||||
legacyAdapters: legacyAdapters.length
|
||||
})
|
||||
await reconcileSeededClaudeLivePtys(routedAdapter)
|
||||
}
|
||||
|
||||
@@ -1124,7 +1277,8 @@ export async function cleanupDaemonForProtocol(
|
||||
didRequestShutdown = true
|
||||
} catch {
|
||||
// Previous-protocol daemons may be wedged or too old for the RPC path; fall back to PID cleanup (only unlinks a live socket after proving the process is killed).
|
||||
didKillStaleDaemon = await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion)
|
||||
didKillStaleDaemon = (await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion))
|
||||
.killed
|
||||
} finally {
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ export type DaemonStartOptions = {
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
startedAtMs?: number
|
||||
publishEndpointOwnership?: DaemonServerOptions['publishEndpointOwnership']
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
/** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */
|
||||
protocolVersion?: number
|
||||
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
|
||||
@@ -15,6 +19,7 @@ export type DaemonStartOptions = {
|
||||
onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair']
|
||||
log?: DaemonFileLog
|
||||
onIdleShutdown?: () => void
|
||||
onRpcShutdown?: () => void
|
||||
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
|
||||
}
|
||||
|
||||
@@ -29,6 +34,12 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
|
||||
...(opts.pidPath ? { pidPath: opts.pidPath } : {}),
|
||||
...(opts.launchNonce ? { launchNonce: opts.launchNonce } : {}),
|
||||
...(opts.startedAtMs ? { startedAtMs: opts.startedAtMs } : {}),
|
||||
...(opts.publishEndpointOwnership
|
||||
? { publishEndpointOwnership: opts.publishEndpointOwnership }
|
||||
: {}),
|
||||
...(opts.entryPath ? { entryPath: opts.entryPath } : {}),
|
||||
...(opts.appVersion ? { appVersion: opts.appVersion } : {}),
|
||||
...(opts.spawnerExecPath ? { spawnerExecPath: opts.spawnerExecPath } : {}),
|
||||
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
|
||||
@@ -38,6 +49,7 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
|
||||
: {}),
|
||||
...(opts.log ? { log: opts.log } : {}),
|
||||
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
|
||||
...(opts.onRpcShutdown ? { onRpcShutdown: opts.onRpcShutdown } : {}),
|
||||
...(opts.initialAdoptionTestConfig
|
||||
? { initialAdoptionTestConfig: opts.initialAdoptionTestConfig }
|
||||
: {})
|
||||
|
||||
@@ -10,7 +10,8 @@ export class DaemonPtyAdapterSubscriptionFanout {
|
||||
|
||||
constructor(
|
||||
private readonly adapters: readonly DaemonPtyAdapter[],
|
||||
onAdapterExit: (id: string) => void
|
||||
onAdapterExit: (id: string) => void,
|
||||
onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void
|
||||
) {
|
||||
for (const adapter of adapters) {
|
||||
this.unsubscribers.push(
|
||||
@@ -24,7 +25,10 @@ export class DaemonPtyAdapterSubscriptionFanout {
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
})
|
||||
}),
|
||||
...(onAdapterIdentityChanged && typeof adapter.onDaemonIdentityChanged === 'function'
|
||||
? [adapter.onDaemonIdentityChanged(() => onAdapterIdentityChanged(adapter))]
|
||||
: [])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
|
||||
export async function probePtyOwners(
|
||||
id: string,
|
||||
routed: IPtyProvider | undefined,
|
||||
possibleOwners: readonly DaemonPtyAdapter[]
|
||||
): Promise<boolean | null> {
|
||||
if (routed) {
|
||||
return routed.probePtyLiveness
|
||||
? await routed.probePtyLiveness(id)
|
||||
: (routed.hasPty?.(id) ?? null)
|
||||
}
|
||||
const results = await Promise.all(possibleOwners.map((provider) => provider.probePtyLiveness(id)))
|
||||
return results.some((result) => result === true)
|
||||
? true
|
||||
: results.every((result) => result === false)
|
||||
? false
|
||||
: null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ type AdapterMock = DaemonPtyAdapter & {
|
||||
emitData: (id: string, data: string, sequenceChars?: number) => void
|
||||
emitBackground: (event: PtyBackgroundStreamEvent) => void
|
||||
emitExit: (id: string, code: number, incarnationId?: string) => void
|
||||
emitIdentityChange: () => void
|
||||
triggerWriteUnavailable: (id: string) => void
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ function createAdapter(
|
||||
const writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
|
||||
const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] =
|
||||
[]
|
||||
const identityChangeListeners: (() => void)[] = []
|
||||
return {
|
||||
protocolVersion,
|
||||
supportsGitCredentialGuardHost: () =>
|
||||
@@ -136,6 +139,15 @@ function createAdapter(
|
||||
}
|
||||
}
|
||||
),
|
||||
onDaemonIdentityChanged: vi.fn((callback: () => void) => {
|
||||
identityChangeListeners.push(callback)
|
||||
return () => {
|
||||
const idx = identityChangeListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
identityChangeListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
ackColdRestore: vi.fn(),
|
||||
clearTombstone: vi.fn(),
|
||||
reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }),
|
||||
@@ -156,6 +168,7 @@ function createAdapter(
|
||||
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
|
||||
}
|
||||
},
|
||||
emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()),
|
||||
triggerWriteUnavailable: (id: string) => {
|
||||
for (const listener of writeUnavailableListeners) {
|
||||
listener({ id })
|
||||
@@ -498,14 +511,16 @@ describe('DaemonPtyRouter', () => {
|
||||
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
|
||||
})
|
||||
|
||||
it('probes every possible daemon owner for an unmapped session', async () => {
|
||||
it('discovers an unmapped live session from one coalesced inventory', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['surviving-session'])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
|
||||
await expect(router.probePtyLiveness('surviving-session')).resolves.toBe(true)
|
||||
expect(current.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session')
|
||||
expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session')
|
||||
expect(current.listProcesses).toHaveBeenCalledOnce()
|
||||
expect(legacy.listProcesses).toHaveBeenCalledOnce()
|
||||
expect(current.probePtyLiveness).not.toHaveBeenCalled()
|
||||
expect(legacy.probePtyLiveness).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report absence while any possible daemon owner is unavailable', async () => {
|
||||
@@ -517,6 +532,102 @@ describe('DaemonPtyRouter', () => {
|
||||
await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('routes an attach to a legacy session created after startup inventory', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacySessions = ['legacy-at-startup']
|
||||
const legacy = createAdapter('legacy', legacySessions)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
legacySessions.push('legacy-created-later')
|
||||
|
||||
await router.spawn({
|
||||
sessionId: 'legacy-created-later',
|
||||
attachOnly: true,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(legacy.spawn).toHaveBeenCalledExactlyOnceWith({
|
||||
sessionId: 'legacy-created-later',
|
||||
attachOnly: true,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(current.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still routes probes for a discovered session to its owning legacy daemon', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
await expect(router.probePtyLiveness('legacy-session')).resolves.toBe(true)
|
||||
expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('legacy-session')
|
||||
})
|
||||
|
||||
it('keeps consulting a legacy daemon whose inventory listing failed', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged'))
|
||||
vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
// Without an inventory nothing proves the legacy daemon doesn't own this id.
|
||||
await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull()
|
||||
expect(legacy.listProcesses).toHaveBeenCalledTimes(2)
|
||||
expect(legacy.probePtyLiveness).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an attach unresolved when any possible owner inventory fails', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy')
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged'))
|
||||
|
||||
await expect(
|
||||
router.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 })
|
||||
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
|
||||
expect(current.spawn).not.toHaveBeenCalled()
|
||||
expect(legacy.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-resolves a stale positive route before declaring an owner absent', async () => {
|
||||
const current = createAdapter('current')
|
||||
const firstSessions = ['moved-session']
|
||||
const secondSessions: string[] = []
|
||||
const first = createAdapter('first', firstSessions)
|
||||
const second = createAdapter('second', secondSessions)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [first, second] })
|
||||
await router.discoverLegacySessions()
|
||||
firstSessions.length = 0
|
||||
secondSessions.push('moved-session')
|
||||
vi.mocked(first.spawn).mockRejectedValueOnce(new SessionNotFoundError('moved-session'))
|
||||
|
||||
await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 })
|
||||
|
||||
expect(first.spawn).toHaveBeenCalledOnce()
|
||||
expect(second.spawn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('invalidates positive routes when a daemon endpoint identity changes', async () => {
|
||||
const currentSessions: string[] = []
|
||||
const legacySessions = ['moved-session']
|
||||
const current = createAdapter('current', currentSessions)
|
||||
const legacy = createAdapter('legacy', legacySessions)
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
legacySessions.length = 0
|
||||
currentSessions.push('moved-session')
|
||||
|
||||
legacy.emitIdentityChange()
|
||||
await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 })
|
||||
|
||||
expect(current.spawn).toHaveBeenCalledOnce()
|
||||
expect(legacy.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => {
|
||||
const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION)
|
||||
const legacy = createAdapter(
|
||||
@@ -582,7 +693,7 @@ describe('DaemonPtyRouter', () => {
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
|
||||
expect(router.getLegacyAdapters()).toEqual([legacy])
|
||||
expect(current.listProcesses).toHaveBeenCalledTimes(2)
|
||||
expect(current.listProcesses).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('pins colliding unmapped legacy ids falling through to the current daemon', async () => {
|
||||
|
||||
@@ -9,44 +9,44 @@ import type {
|
||||
PtySpawnResult
|
||||
} from '../providers/types'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { probePtyOwners } from './daemon-pty-liveness-probe'
|
||||
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
|
||||
|
||||
export class DaemonPtyRouter implements IPtyProvider {
|
||||
private current: DaemonPtyAdapter
|
||||
private legacy: DaemonPtyAdapter[]
|
||||
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
|
||||
private readonly ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
|
||||
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
|
||||
|
||||
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
||||
this.current = opts.current
|
||||
this.legacy = opts.legacy
|
||||
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => {
|
||||
this.sessionAdapters.delete(id)
|
||||
})
|
||||
this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters)
|
||||
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(
|
||||
this.allAdapters(),
|
||||
(id) => {
|
||||
this.ownerResolver.forgetRoute(id)
|
||||
},
|
||||
(adapter) => this.ownerResolver.invalidateProvider(adapter)
|
||||
)
|
||||
}
|
||||
|
||||
async discoverLegacySessions(): Promise<void> {
|
||||
for (const adapter of this.legacy) {
|
||||
try {
|
||||
const sessions = await adapter.listProcesses()
|
||||
for (const session of sessions) {
|
||||
this.sessionAdapters.set(session.id, adapter)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[daemon] Failed to discover legacy daemon sessions', error)
|
||||
}
|
||||
}
|
||||
await this.ownerResolver.discoverRoutes()
|
||||
}
|
||||
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
if (opts.attachOnly && opts.sessionId) {
|
||||
return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
|
||||
}
|
||||
const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined
|
||||
const target = adapter ?? this.current
|
||||
const result = await target.spawn(opts)
|
||||
// Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof.
|
||||
if (!result.exitedBeforeSpawnReply) {
|
||||
this.sessionAdapters.set(result.id, target)
|
||||
this.ownerResolver.recordRoute(result.id, target, result.incarnationId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async probePtyLiveness(id: string): Promise<boolean | null> {
|
||||
return await probePtyOwners(id, this.sessionAdapters.get(id), this.allAdapters())
|
||||
return await this.ownerResolver.probe(id)
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
@@ -121,7 +121,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
adapter.ackColdRestore(id)
|
||||
}
|
||||
if (this.sessionAdapters.get(id) === adapter) {
|
||||
this.sessionAdapters.delete(id)
|
||||
this.ownerResolver.forgetRoute(id, adapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
}> {
|
||||
const alive: string[] = []
|
||||
const killed: string[] = []
|
||||
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
|
||||
for (const adapter of this.allAdapters()) {
|
||||
const result = await adapter.reconcileOnStartup(validWorktreeIds)
|
||||
// Why: daemon startup can reconcile many restored sessions; spreading
|
||||
@@ -251,10 +252,17 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
killed.push(id)
|
||||
}
|
||||
for (const id of result.alive) {
|
||||
this.sessionAdapters.set(id, adapter)
|
||||
const providers = aliveProviders.get(id) ?? new Set<DaemonPtyAdapter>()
|
||||
providers.add(adapter)
|
||||
aliveProviders.set(id, providers)
|
||||
}
|
||||
for (const id of result.killed) {
|
||||
this.sessionAdapters.delete(id)
|
||||
}
|
||||
for (const id of new Set([...alive, ...killed])) {
|
||||
const providers = aliveProviders.get(id)
|
||||
if (providers?.size === 1) {
|
||||
this.ownerResolver.recordRoute(id, providers.values().next().value!)
|
||||
} else {
|
||||
this.ownerResolver.forgetRoute(id)
|
||||
}
|
||||
}
|
||||
return { alive, killed }
|
||||
|
||||
@@ -22,6 +22,27 @@ export async function readCurrentDaemonReadyIdentity(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads another process's incarnation markers.
|
||||
*
|
||||
* Why: `readCurrentDaemonReadyIdentity` only covers /proc/self, but repairing a PID record
|
||||
* means republishing the markers of the daemon that actually owns the endpoint.
|
||||
*/
|
||||
export async function readDaemonProcessIncarnation(
|
||||
pid: number
|
||||
): Promise<{ linuxStartTicks: string; bootId: string } | null> {
|
||||
if (process.platform !== 'linux') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const linuxStartTicks = parseLinuxStartTicks(readFileSync(`/proc/${pid}/stat`, 'utf8'))
|
||||
const bootId = await readBootIdentity()
|
||||
return linuxStartTicks && bootId ? { linuxStartTicks, bootId } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null
|
||||
|
||||
@@ -87,11 +87,12 @@ describe('DaemonServer', () => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function startServer(launchNonce?: string): Promise<void> {
|
||||
async function startServer(launchNonce?: string, onRpcShutdown?: () => void): Promise<void> {
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(launchNonce ? { pidPath, launchNonce } : {}),
|
||||
...(onRpcShutdown ? { onRpcShutdown } : {}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
@@ -842,7 +843,8 @@ describe('DaemonServer', () => {
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('waits for the ordinary shutdown reply write before destroying resources', async () => {
|
||||
await startServer()
|
||||
const onRpcShutdown = vi.fn()
|
||||
await startServer(undefined, onRpcShutdown)
|
||||
const c = await connectClient()
|
||||
const daemon = server as unknown as DaemonServerPrivate & {
|
||||
host: { dispose: () => Promise<void> }
|
||||
@@ -861,11 +863,14 @@ describe('DaemonServer', () => {
|
||||
|
||||
await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({})
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
expect(onRpcShutdown).not.toHaveBeenCalled()
|
||||
expect(existsSync(tokenPath)).toBe(true)
|
||||
|
||||
replyFlushed?.()
|
||||
await waitFor(() => !existsSync(tokenPath))
|
||||
await waitFor(() => onRpcShutdown.mock.calls.length === 1)
|
||||
expect(existsSync(tokenPath)).toBe(false)
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(onRpcShutdown).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('removes only its owned token and PID record', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createServer, type Server, type Socket } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { writeFileSync, chmodSync } from 'node:fs'
|
||||
import { writeFileSync, chmodSync, unlinkSync } from 'node:fs'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { encodeNdjson, createNdjsonParser } from './ndjson'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
@@ -23,6 +23,13 @@ import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
|
||||
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
|
||||
import {
|
||||
getDaemonSocketBindPath,
|
||||
publishDaemonSocketPath,
|
||||
readDaemonEndpointOwnershipState,
|
||||
unlinkOwnedDaemonSocketPath,
|
||||
type DaemonSocketIdentity
|
||||
} from './daemon-endpoint-ownership'
|
||||
import {
|
||||
CLEAN_DISCONNECT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -44,9 +51,15 @@ export type DaemonServerOptions = {
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
startedAtMs?: number
|
||||
publishEndpointOwnership?: () => void
|
||||
/** Reported in the hello so a repaired PID record can carry the real owner's metadata. */
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
spawnerExecPath?: string
|
||||
/** Direct-construction seam for protocol fixture tests; production never overrides it. */
|
||||
protocolVersion?: number
|
||||
onIdleShutdown?: () => void
|
||||
onRpcShutdown?: () => void
|
||||
/** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */
|
||||
initialAdoptionTestConfig?: {
|
||||
timeoutMs: number
|
||||
@@ -95,6 +108,8 @@ export class DaemonServer {
|
||||
// Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first.
|
||||
private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000
|
||||
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
|
||||
private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000
|
||||
private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2
|
||||
private server: Server | null = null
|
||||
private token: string
|
||||
private host: TerminalHost
|
||||
@@ -103,8 +118,16 @@ export class DaemonServer {
|
||||
private pidPath: string | null
|
||||
private launchNonce: string | null
|
||||
private startedAtMs: number | null
|
||||
private publishEndpointOwnership: () => void
|
||||
private entryPath: string | null
|
||||
private appVersion: string | null
|
||||
private spawnerExecPath: string | null
|
||||
private ownedSocketIdentity: DaemonSocketIdentity | null = null
|
||||
private endpointOwnershipTimer: ReturnType<typeof setInterval> | null = null
|
||||
private endpointOwnershipLossStreak = 0
|
||||
private protocolVersion: number
|
||||
private onIdleShutdown: () => void
|
||||
private onRpcShutdown: () => void
|
||||
private onAuthenticatedClientPair: () => void
|
||||
private ptySpawnHealthCheck: () => Promise<void>
|
||||
private preparePtySpawn: () => Promise<void>
|
||||
@@ -173,7 +196,12 @@ export class DaemonServer {
|
||||
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION
|
||||
? Date.now() - process.uptime() * 1000
|
||||
: null)
|
||||
this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {})
|
||||
this.entryPath = opts.entryPath ?? null
|
||||
this.appVersion = opts.appVersion ?? null
|
||||
this.spawnerExecPath = opts.spawnerExecPath ?? null
|
||||
this.onIdleShutdown = opts.onIdleShutdown ?? (() => {})
|
||||
this.onRpcShutdown = opts.onRpcShutdown ?? (() => {})
|
||||
this.initialAdoptionTimeoutMs =
|
||||
opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS
|
||||
this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? {
|
||||
@@ -213,19 +241,58 @@ export class DaemonServer {
|
||||
|
||||
this.server.once('error', onListenError)
|
||||
|
||||
this.server.listen(this.socketPath, () => {
|
||||
// Why: bind a private name and hard-link it into place, so libuv's close-time unlink
|
||||
// can only ever remove our own bind name — never a replacement daemon's endpoint.
|
||||
const bindPath =
|
||||
process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath)
|
||||
|
||||
this.server.listen(bindPath, () => {
|
||||
// Why: drop the startup error listener after bind so it doesn't retain this closure.
|
||||
this.server?.off('error', onListenError)
|
||||
writeFileSync(this.tokenPath, this.token, { mode: 0o600 })
|
||||
try {
|
||||
chmodSync(this.socketPath, 0o600)
|
||||
// Why: tighten the mode on the private bind name so the endpoint is never reachable
|
||||
// at the canonical path with default permissions, even briefly.
|
||||
chmodSync(bindPath, 0o600)
|
||||
} catch {
|
||||
// Best-effort on platforms that support it
|
||||
}
|
||||
let publishedOwnership = false
|
||||
try {
|
||||
// Why: the exclusive link is the endpoint claim, and the PID/nonce record must
|
||||
// exist before the token makes this listener adoptable.
|
||||
this.ownedSocketIdentity = publishDaemonSocketPath(bindPath, this.socketPath)
|
||||
this.publishEndpointOwnership()
|
||||
publishedOwnership = true
|
||||
writeFileSync(this.tokenPath, this.token, { mode: 0o600 })
|
||||
} catch (error) {
|
||||
// Why: roll back only a record we actually wrote. Losing the endpoint claim means
|
||||
// the record at that path belongs to the incumbent daemon, and even the ownership-
|
||||
// checked unlink briefly renames it aside — enough to strand a live daemon's record.
|
||||
if (publishedOwnership && this.pidPath && this.launchNonce) {
|
||||
unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
|
||||
}
|
||||
unlinkOwnedDaemonSocketPath(this.socketPath, this.ownedSocketIdentity)
|
||||
this.ownedSocketIdentity = null
|
||||
const server = this.server
|
||||
this.server = null
|
||||
// Why: settle before close — an already-accepted connection defers the close
|
||||
// callback indefinitely, and start() has no timeout of its own.
|
||||
reject(error)
|
||||
server?.close()
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
unlinkSync(bindPath)
|
||||
} catch {
|
||||
// Already consumed by a successful link, or never created.
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
|
||||
// Why: a parent crash before the first full client pair must not leave an empty daemon alive forever.
|
||||
this.armInitialAdoptionTimeout()
|
||||
}
|
||||
this.startEndpointOwnershipWatch()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -253,15 +320,87 @@ export class DaemonServer {
|
||||
await serverClose
|
||||
}
|
||||
|
||||
private async finishRpcShutdown(serverClose: Promise<void>): Promise<void> {
|
||||
await this.finishOrdinaryShutdown(serverClose)
|
||||
this.onRpcShutdown()
|
||||
}
|
||||
|
||||
private unlinkOwnedEndpointArtifacts(): void {
|
||||
// Why: ownership checks prevent removing a late replacement's token or PID record.
|
||||
// Why: ownership checks prevent removing a late replacement's token, PID record or endpoint.
|
||||
unlinkOwnedDaemonTokenFile(this.tokenPath, this.token)
|
||||
if (this.pidPath && this.launchNonce) {
|
||||
unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
|
||||
}
|
||||
// Why: we bound a private name, so libuv unlinks nothing at the canonical path — this
|
||||
// is the only removal of our endpoint, and it is skipped once someone else owns it.
|
||||
unlinkOwnedDaemonSocketPath(this.socketPath, this.ownedSocketIdentity)
|
||||
this.ownedSocketIdentity = null
|
||||
}
|
||||
|
||||
private startEndpointOwnershipWatch(): void {
|
||||
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
|
||||
return
|
||||
}
|
||||
this.endpointOwnershipTimer = setInterval(
|
||||
() => this.checkEndpointOwnership(),
|
||||
DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS
|
||||
)
|
||||
// Why: a liveness poll must never be the reason the process cannot exit.
|
||||
this.endpointOwnershipTimer.unref()
|
||||
}
|
||||
|
||||
private stopEndpointOwnershipWatch(): void {
|
||||
if (this.endpointOwnershipTimer === null) {
|
||||
return
|
||||
}
|
||||
clearInterval(this.endpointOwnershipTimer)
|
||||
this.endpointOwnershipTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires a daemon that no longer owns the canonical endpoint.
|
||||
*
|
||||
* Why: a daemon whose endpoint name was taken over keeps hosting PTYs that no client can
|
||||
* reach through the socket, which reads to the user as terminals that acknowledge input and
|
||||
* never run it. Retirement drains rather than kills: live sessions finish, and the process
|
||||
* exits once idle instead of surviving as an unreachable orphan.
|
||||
*/
|
||||
private checkEndpointOwnership(): void {
|
||||
if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) {
|
||||
return
|
||||
}
|
||||
const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity)
|
||||
if (state === 'owned') {
|
||||
this.endpointOwnershipLossStreak = 0
|
||||
return
|
||||
}
|
||||
if (state === 'indeterminate') {
|
||||
// Why: an inconclusive stat proves nothing. Retiring on EACCES or EIO would take down a
|
||||
// daemon that is still serving every terminal on the machine. Reset the streak too, so
|
||||
// the confirmations we act on are consecutive rather than merely cumulative.
|
||||
this.endpointOwnershipLossStreak = 0
|
||||
return
|
||||
}
|
||||
this.endpointOwnershipLossStreak++
|
||||
// Why: a replacement publishes by unlink-then-link, so a single observation can land in
|
||||
// that gap. Require the loss to persist before acting on it.
|
||||
if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) {
|
||||
return
|
||||
}
|
||||
if (this.retirementRequested) {
|
||||
return
|
||||
}
|
||||
this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath })
|
||||
console.warn(
|
||||
'[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end'
|
||||
)
|
||||
this.ownedSocketIdentity = null
|
||||
this.retirementRequested = true
|
||||
this.reevaluateIdleShutdown()
|
||||
}
|
||||
|
||||
private async disposeDaemonResources(): Promise<void> {
|
||||
this.stopEndpointOwnershipWatch()
|
||||
this.stopStreamBacklogProbe()
|
||||
this.transientFactRelay.dispose()
|
||||
this.cancelAllPendingPtySpawnPreparations()
|
||||
@@ -297,7 +436,9 @@ export class DaemonServer {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof.
|
||||
server.close(() => {
|
||||
// Node owns unlinking its Unix listener; an extra unlink here could delete a concurrent replacement.
|
||||
// Why: libuv unlinks the path this server bound, which is our private bind name and
|
||||
// is already gone. The canonical endpoint is removed by unlinkOwnedEndpointArtifacts,
|
||||
// under an ownership check, so closing late cannot delete a replacement's endpoint.
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -436,19 +577,31 @@ export class DaemonServer {
|
||||
reason: 'protocol-mismatch',
|
||||
clientVersion: hello.version
|
||||
})
|
||||
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' }))
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
ok: false,
|
||||
error: 'Protocol version mismatch'
|
||||
})
|
||||
)
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
if (hello.token !== this.token) {
|
||||
this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role })
|
||||
this.log.log('client-hello-rejected', {
|
||||
reason: 'invalid-token',
|
||||
role: hello.role
|
||||
})
|
||||
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' }))
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId })
|
||||
this.log.log('client-hello-accepted', {
|
||||
role: hello.role,
|
||||
clientId: hello.clientId
|
||||
})
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
@@ -458,7 +611,10 @@ export class DaemonServer {
|
||||
daemonIdentity: {
|
||||
pid: process.pid,
|
||||
startedAtMs: this.startedAtMs,
|
||||
launchNonce: this.launchNonce
|
||||
launchNonce: this.launchNonce,
|
||||
...(this.entryPath ? { entryPath: this.entryPath } : {}),
|
||||
...(this.appVersion ? { appVersion: this.appVersion } : {}),
|
||||
...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
@@ -639,7 +795,10 @@ export class DaemonServer {
|
||||
}
|
||||
|
||||
private async preparePtySpawnUnlessCanceled(sessionId: string, clientId: string): Promise<void> {
|
||||
const preparation: PendingPtySpawnPreparation = { canceled: false, clientId }
|
||||
const preparation: PendingPtySpawnPreparation = {
|
||||
canceled: false,
|
||||
clientId
|
||||
}
|
||||
const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set()
|
||||
pending.add(preparation)
|
||||
this.pendingPtySpawnPreparations.set(sessionId, pending)
|
||||
@@ -790,7 +949,10 @@ export class DaemonServer {
|
||||
},
|
||||
onExit: (code, incarnationId) => {
|
||||
// Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output.
|
||||
this.log.log('session-exited', { sessionId: routedSessionId, code })
|
||||
this.log.log('session-exited', {
|
||||
sessionId: routedSessionId,
|
||||
code
|
||||
})
|
||||
this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
@@ -942,7 +1104,9 @@ export class DaemonServer {
|
||||
clientId
|
||||
}
|
||||
try {
|
||||
await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
|
||||
await this.host.kill(request.payload.sessionId, {
|
||||
immediate: request.payload.immediate
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done.
|
||||
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
|
||||
@@ -960,14 +1124,18 @@ export class DaemonServer {
|
||||
|
||||
case 'detach':
|
||||
// Note: detach token handling simplified — full impl would track tokens per client
|
||||
this.log.log('session-detached', { sessionId: request.payload.sessionId })
|
||||
this.log.log('session-detached', {
|
||||
sessionId: request.payload.sessionId
|
||||
})
|
||||
return {}
|
||||
|
||||
case 'getCwd':
|
||||
return { cwd: await this.host.getCwd(request.payload.sessionId) }
|
||||
|
||||
case 'getForegroundProcess':
|
||||
return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) }
|
||||
return {
|
||||
foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId)
|
||||
}
|
||||
|
||||
case 'inspectProcess':
|
||||
return this.host.inspectProcess(request.payload.sessionId)
|
||||
@@ -1019,7 +1187,9 @@ export class DaemonServer {
|
||||
typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows)
|
||||
? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows)))
|
||||
: undefined
|
||||
const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows })
|
||||
const snapshot = this.host.getSnapshot(request.payload.sessionId, {
|
||||
scrollbackRows
|
||||
})
|
||||
const snapshotMs = performance.now() - snapshotStart
|
||||
if (snapshotMs >= 25) {
|
||||
// Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family).
|
||||
@@ -1071,10 +1241,10 @@ export class DaemonServer {
|
||||
const controlSocket = this.clients.get(clientId)?.controlSocket
|
||||
if (controlSocket) {
|
||||
this.deferShutdownUntilReply(clientId, request.id, controlSocket, () =>
|
||||
this.finishOrdinaryShutdown(serverClose)
|
||||
this.finishRpcShutdown(serverClose)
|
||||
)
|
||||
} else if (!this.shutdownPromise) {
|
||||
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
|
||||
this.shutdownPromise = this.finishRpcShutdown(serverClose)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyProcessInfo,
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
} from '../providers/types'
|
||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
|
||||
|
||||
function provider(
|
||||
processes: () => Promise<PtyProcessInfo[]>,
|
||||
spawn: (opts: PtySpawnOptions) => Promise<PtySpawnResult> = async (opts) => ({
|
||||
id: opts.sessionId ?? 'new'
|
||||
})
|
||||
): IPtyProvider {
|
||||
return {
|
||||
listProcesses: vi.fn(processes),
|
||||
spawn: vi.fn(spawn)
|
||||
} as unknown as IPtyProvider
|
||||
}
|
||||
|
||||
describe('DaemonSessionOwnerResolver', () => {
|
||||
it('coalesces concurrent all-provider inventories', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const firstInventory = vi.fn(async () => {
|
||||
await gate
|
||||
return []
|
||||
})
|
||||
const secondInventory = vi.fn(async () => {
|
||||
await gate
|
||||
return []
|
||||
})
|
||||
const first = provider(firstInventory)
|
||||
const second = provider(secondInventory)
|
||||
const resolver = new DaemonSessionOwnerResolver([first, second], new Map())
|
||||
|
||||
const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')])
|
||||
await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce())
|
||||
expect(secondInventory).toHaveBeenCalledOnce()
|
||||
release()
|
||||
|
||||
await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }])
|
||||
expect(firstInventory).toHaveBeenCalledOnce()
|
||||
expect(secondInventory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bounds restore inventory requests across many panes and daemon generations', async () => {
|
||||
const inventories = Array.from({ length: 51 }, (_, index) =>
|
||||
vi.fn(async () => {
|
||||
if (index === 50) {
|
||||
throw new Error('offline')
|
||||
}
|
||||
return []
|
||||
})
|
||||
)
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
inventories.map((inventory) => provider(inventory)),
|
||||
new Map()
|
||||
)
|
||||
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
await resolver.resolve(`pane-${index}`)
|
||||
}
|
||||
|
||||
for (const inventory of inventories) {
|
||||
expect(inventory).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses incomplete inventory across serialized restores', async () => {
|
||||
const firstInventory = vi.fn(async () => [])
|
||||
const secondInventory = vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[provider(firstInventory), provider(secondInventory)],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await resolver.resolve('first')
|
||||
await resolver.resolve('second')
|
||||
|
||||
expect(firstInventory).toHaveBeenCalledOnce()
|
||||
expect(secondInventory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not extend incomplete inventory lifetime on cache hits', async () => {
|
||||
let now = 1_000
|
||||
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
const inventory = vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[provider(async () => []), provider(inventory)],
|
||||
new Map()
|
||||
)
|
||||
|
||||
try {
|
||||
await resolver.resolve('first')
|
||||
now += 900
|
||||
await resolver.resolve('second')
|
||||
now += 101
|
||||
await resolver.resolve('third')
|
||||
|
||||
expect(inventory).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let startup inventory hide a session created before restore', async () => {
|
||||
const sessions: PtyProcessInfo[] = []
|
||||
const inventory = vi.fn(async () => sessions)
|
||||
const owner = provider(inventory, async (opts) => ({
|
||||
id: opts.sessionId!,
|
||||
incarnationId: 'live',
|
||||
isReattach: true
|
||||
}))
|
||||
const resolver = new DaemonSessionOwnerResolver([owner, provider(async () => [])], new Map())
|
||||
|
||||
await resolver.discoverRoutes()
|
||||
sessions.push({ id: 'session', incarnationId: 'live', cwd: '', title: 'live' })
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
|
||||
).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true })
|
||||
expect(inventory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses the expected incarnation to disambiguate duplicate session ids', async () => {
|
||||
const oldOwner = provider(async () => [
|
||||
{ id: 'session', incarnationId: 'old', cwd: '', title: 'old' }
|
||||
])
|
||||
const exactOwner = provider(async () => [
|
||||
{ id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' }
|
||||
])
|
||||
const resolver = new DaemonSessionOwnerResolver([oldOwner, exactOwner], new Map())
|
||||
|
||||
await expect(resolver.resolve('session', 'expected')).resolves.toMatchObject({
|
||||
kind: 'owner',
|
||||
provider: exactOwner
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts one exact-id owner when persisted incarnation evidence is stale', async () => {
|
||||
const liveOwner = provider(async () => [
|
||||
{ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }
|
||||
])
|
||||
const resolver = new DaemonSessionOwnerResolver([liveOwner], new Map())
|
||||
|
||||
await expect(resolver.resolve('session', 'stale')).resolves.toMatchObject({
|
||||
kind: 'owner',
|
||||
provider: liveOwner
|
||||
})
|
||||
})
|
||||
|
||||
it('does not inventory unrelated providers for a routed stale persisted incarnation', async () => {
|
||||
const routedOwner = provider(
|
||||
async () => [{ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }],
|
||||
async () => ({ id: 'session', incarnationId: 'live', isReattach: true })
|
||||
)
|
||||
const unrelatedInventory = vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[routedOwner, provider(unrelatedInventory)],
|
||||
new Map([['session', routedOwner]])
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({
|
||||
sessionId: 'session',
|
||||
expectedIncarnationId: 'stale',
|
||||
attachOnly: true,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true })
|
||||
expect(unrelatedInventory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-resolves a routed incarnation mismatch against every possible owner', async () => {
|
||||
const staleRoute = provider(
|
||||
async () => [{ id: 'session', incarnationId: 'other', cwd: '', title: 'stale' }],
|
||||
async () => ({ id: 'session', incarnationId: 'other', isReattach: true })
|
||||
)
|
||||
const exactOwner = provider(
|
||||
async () => [{ id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' }],
|
||||
async () => ({ id: 'session', incarnationId: 'expected', isReattach: true })
|
||||
)
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[staleRoute, exactOwner],
|
||||
new Map([['session', staleRoute]])
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({
|
||||
sessionId: 'session',
|
||||
expectedIncarnationId: 'expected',
|
||||
expectedIncarnationIsAuthoritative: true,
|
||||
attachOnly: true,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
).resolves.toMatchObject({ incarnationId: 'expected' })
|
||||
expect(staleRoute.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes incomplete inventory when a routed owner refuses a moved session', async () => {
|
||||
let firstOwnsSession = true
|
||||
let secondOwnsSession = false
|
||||
const first = provider(
|
||||
async () =>
|
||||
firstOwnsSession
|
||||
? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'first' }]
|
||||
: [],
|
||||
async () => {
|
||||
throw new SessionNotFoundError('session')
|
||||
}
|
||||
)
|
||||
const second = provider(
|
||||
async () =>
|
||||
secondOwnsSession
|
||||
? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'second' }]
|
||||
: [],
|
||||
async () => ({ id: 'session', incarnationId: 'runtime', isReattach: true })
|
||||
)
|
||||
const unavailable = provider(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
const resolver = new DaemonSessionOwnerResolver([first, second, unavailable], new Map())
|
||||
|
||||
await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({
|
||||
kind: 'owner',
|
||||
provider: first
|
||||
})
|
||||
firstOwnsSession = false
|
||||
secondOwnsSession = true
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({
|
||||
sessionId: 'session',
|
||||
expectedIncarnationId: 'runtime',
|
||||
expectedIncarnationIsAuthoritative: true,
|
||||
attachOnly: true,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'session', incarnationId: 'runtime', isReattach: true })
|
||||
expect(first.spawn).toHaveBeenCalledOnce()
|
||||
expect(second.spawn).toHaveBeenCalledOnce()
|
||||
expect(second.listProcesses).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('accepts exact runtime proof from an incomplete inventory', async () => {
|
||||
const exactOwner = provider(async () => [
|
||||
{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'exact' }
|
||||
])
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[
|
||||
exactOwner,
|
||||
provider(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({
|
||||
kind: 'owner',
|
||||
provider: exactOwner
|
||||
})
|
||||
})
|
||||
|
||||
it('does not trust persisted incarnation proof from an incomplete inventory', async () => {
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[
|
||||
provider(async () => [
|
||||
{ id: 'session', incarnationId: 'persisted', cwd: '', title: 'candidate' }
|
||||
]),
|
||||
provider(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await expect(resolver.resolve('session', 'persisted')).resolves.toEqual({ kind: 'unknown' })
|
||||
})
|
||||
|
||||
it('accepts positive liveness proof from an incomplete inventory', async () => {
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[
|
||||
provider(async () => [{ id: 'session', cwd: '', title: 'live' }]),
|
||||
provider(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await expect(resolver.probe('session')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when duplicate owners cannot be disambiguated', async () => {
|
||||
const first = provider(async () => [{ id: 'session', cwd: '', title: 'first' }])
|
||||
const second = provider(async () => [{ id: 'session', cwd: '', title: 'second' }])
|
||||
const resolver = new DaemonSessionOwnerResolver([first, second], new Map())
|
||||
|
||||
await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' })
|
||||
})
|
||||
|
||||
it('reports confirmed absence from a sole owner', async () => {
|
||||
const owner = provider(
|
||||
async () => [],
|
||||
async () => {
|
||||
throw new SessionNotFoundError('missing')
|
||||
}
|
||||
)
|
||||
const resolver = new DaemonSessionOwnerResolver([owner], new Map())
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 })
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError)
|
||||
expect(owner.spawn).toHaveBeenCalledOnce()
|
||||
expect(owner.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps absence unverified when there are no possible owners', async () => {
|
||||
const resolver = new DaemonSessionOwnerResolver([], new Map())
|
||||
|
||||
await expect(resolver.resolve('missing')).resolves.toEqual({ kind: 'unknown' })
|
||||
})
|
||||
|
||||
it('preserves an unresolved owner when any provider inventory fails', async () => {
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[
|
||||
provider(async () => []),
|
||||
provider(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 })
|
||||
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
|
||||
})
|
||||
|
||||
it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => {
|
||||
const candidate = provider(
|
||||
async () => [{ id: 'session', cwd: '', title: 'candidate' }],
|
||||
async () => {
|
||||
throw new SessionNotFoundError('session')
|
||||
}
|
||||
)
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[
|
||||
candidate,
|
||||
provider(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
|
||||
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
|
||||
})
|
||||
|
||||
it('does not turn a post-inventory owner refusal into aggregate absence', async () => {
|
||||
const candidate = provider(
|
||||
async () => [{ id: 'session', cwd: '', title: 'candidate' }],
|
||||
async () => {
|
||||
throw new SessionNotFoundError('session')
|
||||
}
|
||||
)
|
||||
const resolver = new DaemonSessionOwnerResolver(
|
||||
[candidate, provider(async () => [])],
|
||||
new Map()
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
|
||||
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
|
||||
})
|
||||
|
||||
it('pre-routes every uniquely inventoried session for serialized restores', async () => {
|
||||
const inventory = vi.fn(async () => [
|
||||
{ id: 'first', cwd: '', title: 'first' },
|
||||
{ id: 'second', cwd: '', title: 'second' }
|
||||
])
|
||||
const owner = provider(inventory)
|
||||
const emptyInventory = vi.fn(async () => [])
|
||||
const resolver = new DaemonSessionOwnerResolver([owner, provider(emptyInventory)], new Map())
|
||||
|
||||
await resolver.spawnAttachOnly({ sessionId: 'first', attachOnly: true, cols: 80, rows: 24 })
|
||||
await resolver.spawnAttachOnly({ sessionId: 'second', attachOnly: true, cols: 80, rows: 24 })
|
||||
|
||||
expect(inventory).toHaveBeenCalledOnce()
|
||||
expect(emptyInventory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('discards an inventory completed after daemon identity replacement', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const inventory = vi.fn(async () => {
|
||||
await gate
|
||||
return [{ id: 'session', cwd: '', title: 'stale' }]
|
||||
})
|
||||
const owner = provider(inventory)
|
||||
const resolver = new DaemonSessionOwnerResolver([owner], new Map())
|
||||
|
||||
const resolution = resolver.resolve('session')
|
||||
await vi.waitFor(() => expect(inventory).toHaveBeenCalledOnce())
|
||||
resolver.invalidateProvider(owner)
|
||||
release()
|
||||
|
||||
await expect(resolution).resolves.toEqual({ kind: 'unknown' })
|
||||
await resolver.resolve('session')
|
||||
expect(inventory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restores a proven route when identity changes during exact reattach', async () => {
|
||||
const routes = new Map<string, IPtyProvider>()
|
||||
let resolver!: DaemonSessionOwnerResolver<IPtyProvider>
|
||||
let owner!: IPtyProvider
|
||||
owner = provider(
|
||||
async () => [],
|
||||
async () => {
|
||||
resolver.invalidateProvider(owner)
|
||||
return { id: 'session', incarnationId: 'inc', isReattach: true }
|
||||
}
|
||||
)
|
||||
routes.set('session', owner)
|
||||
resolver = new DaemonSessionOwnerResolver([owner], routes)
|
||||
|
||||
await resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 })
|
||||
|
||||
expect(routes.get('session')).toBe(owner)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyProcessInfo,
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
} from '../providers/types'
|
||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
|
||||
export type DaemonSessionOwnerResolution<T extends IPtyProvider> =
|
||||
| { kind: 'owner'; provider: T }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
type ProviderInventory<T> = { provider: T; processes: PtyProcessInfo[] | null }
|
||||
|
||||
type OwnerInventory<T extends IPtyProvider> = {
|
||||
candidatesBySessionId: Map<string, { provider: T; process: PtyProcessInfo }[]>
|
||||
complete: boolean
|
||||
epoch: number
|
||||
}
|
||||
|
||||
const OWNER_RESOLUTION_TIMEOUT_MS = 2_000
|
||||
const OWNER_INVENTORY_CACHE_MS = 1_000
|
||||
const FAILED_PROVIDER_COOLDOWN_MS = 1_000
|
||||
|
||||
function assertClientConnected(signal: PtySpawnOptions['signal']): void {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
|
||||
private inventoryInFlight: Promise<OwnerInventory<T>> | null = null
|
||||
private cachedInventory: { value: OwnerInventory<T>; expiresAt: number } | null = null
|
||||
private readonly failedProviderCooldowns = new Map<T, number>()
|
||||
private readonly routeIncarnations = new Map<string, string | undefined>()
|
||||
private epoch = 0
|
||||
|
||||
constructor(
|
||||
private readonly providers: readonly T[],
|
||||
private readonly routes: Map<string, IPtyProvider>
|
||||
) {}
|
||||
|
||||
invalidateProvider(provider: T): void {
|
||||
this.epoch += 1
|
||||
this.inventoryInFlight = null
|
||||
this.cachedInventory = null
|
||||
this.failedProviderCooldowns.clear()
|
||||
for (const [sessionId, routed] of this.routes) {
|
||||
if (routed === provider) {
|
||||
this.routes.delete(sessionId)
|
||||
this.routeIncarnations.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async spawnAttachOnly(opts: PtySpawnOptions & { sessionId: string }): Promise<PtySpawnResult> {
|
||||
assertClientConnected(opts.signal)
|
||||
const routed = this.providers.find((provider) => provider === this.routes.get(opts.sessionId))
|
||||
const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined)
|
||||
const routedIncarnation = this.routeIncarnations.get(opts.sessionId)
|
||||
const routeNeedsAuthoritativeResolution =
|
||||
direct &&
|
||||
routed &&
|
||||
opts.expectedIncarnationIsAuthoritative === true &&
|
||||
routedIncarnation !== opts.expectedIncarnationId
|
||||
if (direct && !routeNeedsAuthoritativeResolution) {
|
||||
try {
|
||||
const result = await direct.spawn(opts)
|
||||
if (
|
||||
!result.exitedBeforeSpawnReply &&
|
||||
result.id === opts.sessionId &&
|
||||
result.isReattach === true
|
||||
) {
|
||||
this.recordRoute(result.id, direct, result.incarnationId)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (!(error instanceof SessionNotFoundError)) {
|
||||
throw error
|
||||
}
|
||||
if (this.providers.length === 1) {
|
||||
throw error
|
||||
}
|
||||
if (routed && this.routes.get(opts.sessionId) === routed) {
|
||||
this.forgetRoute(opts.sessionId, routed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertClientConnected(opts.signal)
|
||||
const resolution = await this.resolve(
|
||||
opts.sessionId,
|
||||
opts.expectedIncarnationId,
|
||||
opts.expectedIncarnationIsAuthoritative
|
||||
)
|
||||
assertClientConnected(opts.signal)
|
||||
if (resolution.kind === 'unknown') {
|
||||
throw new TerminalSessionOwnerUnverifiedError(opts.sessionId)
|
||||
}
|
||||
if (resolution.kind === 'absent') {
|
||||
throw new SessionNotFoundError(opts.sessionId)
|
||||
}
|
||||
try {
|
||||
const result = await resolution.provider.spawn(opts)
|
||||
if (
|
||||
!result.exitedBeforeSpawnReply &&
|
||||
result.id === opts.sessionId &&
|
||||
result.isReattach === true
|
||||
) {
|
||||
this.recordRoute(result.id, resolution.provider, result.incarnationId)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError && this.providers.length > 1) {
|
||||
throw new TerminalSessionOwnerUnverifiedError(opts.sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async probe(sessionId: string): Promise<boolean | null> {
|
||||
const routed = this.providers.find((provider) => provider === this.routes.get(sessionId))
|
||||
const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined)
|
||||
if (direct) {
|
||||
const verdict = direct.probePtyLiveness
|
||||
? await direct.probePtyLiveness(sessionId)
|
||||
: (direct.hasPty?.(sessionId) ?? null)
|
||||
if (verdict !== false || this.providers.length === 1) {
|
||||
return verdict
|
||||
}
|
||||
if (this.routes.get(sessionId) === routed) {
|
||||
this.routes.delete(sessionId)
|
||||
this.routeIncarnations.delete(sessionId)
|
||||
}
|
||||
}
|
||||
const inventory = await this.inventory(false)
|
||||
if (inventory.epoch !== this.epoch) {
|
||||
return null
|
||||
}
|
||||
if ((inventory.candidatesBySessionId.get(sessionId)?.length ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
const resolution = this.resolveInventory(inventory, sessionId)
|
||||
if (resolution.kind === 'owner') {
|
||||
return resolution.provider === routed ? null : true
|
||||
}
|
||||
return resolution.kind === 'absent' ? false : null
|
||||
}
|
||||
|
||||
async resolve(
|
||||
sessionId: string,
|
||||
expectedIncarnationId?: string,
|
||||
expectedIncarnationIsAuthoritative = false
|
||||
): Promise<DaemonSessionOwnerResolution<T>> {
|
||||
const inventory = await this.inventory()
|
||||
if (inventory.epoch !== this.epoch) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
const resolution = this.resolveInventory(
|
||||
inventory,
|
||||
sessionId,
|
||||
expectedIncarnationId,
|
||||
expectedIncarnationIsAuthoritative
|
||||
)
|
||||
if (
|
||||
(!inventory.complete || resolution.kind === 'unknown') &&
|
||||
this.cachedInventory?.value !== inventory
|
||||
) {
|
||||
this.cachedInventory = {
|
||||
value: inventory,
|
||||
expiresAt: Date.now() + OWNER_INVENTORY_CACHE_MS
|
||||
}
|
||||
}
|
||||
return resolution
|
||||
}
|
||||
|
||||
async discoverRoutes(): Promise<void> {
|
||||
await this.inventory()
|
||||
this.failedProviderCooldowns.clear()
|
||||
this.cachedInventory = null
|
||||
}
|
||||
|
||||
recordRoute(sessionId: string, provider: T, incarnationId?: string): void {
|
||||
this.routes.set(sessionId, provider)
|
||||
this.routeIncarnations.set(sessionId, incarnationId)
|
||||
}
|
||||
|
||||
forgetRoute(sessionId: string, provider?: T): void {
|
||||
if (provider && this.routes.get(sessionId) !== provider) {
|
||||
return
|
||||
}
|
||||
this.routes.delete(sessionId)
|
||||
this.routeIncarnations.delete(sessionId)
|
||||
this.cachedInventory = null
|
||||
}
|
||||
|
||||
private resolveInventory(
|
||||
inventory: OwnerInventory<T>,
|
||||
sessionId: string,
|
||||
expectedIncarnationId?: string,
|
||||
expectedIncarnationIsAuthoritative = false
|
||||
): DaemonSessionOwnerResolution<T> {
|
||||
const candidates = inventory.candidatesBySessionId.get(sessionId) ?? []
|
||||
const providers = new Set(candidates.map(({ provider }) => provider))
|
||||
const exactProviders = new Set(
|
||||
candidates
|
||||
.filter(({ process }) => process.incarnationId === expectedIncarnationId)
|
||||
.map(({ provider }) => provider)
|
||||
)
|
||||
const exactProvider =
|
||||
expectedIncarnationId && exactProviders.size === 1
|
||||
? exactProviders.values().next().value
|
||||
: undefined
|
||||
const soleProvider = providers.size === 1 ? providers.values().next().value : undefined
|
||||
const provider =
|
||||
(exactProvider && (inventory.complete || expectedIncarnationIsAuthoritative)
|
||||
? exactProvider
|
||||
: undefined) ??
|
||||
(!expectedIncarnationIsAuthoritative && inventory.complete ? soleProvider : undefined)
|
||||
if (provider) {
|
||||
const process = candidates.find((candidate) => candidate.provider === provider)?.process
|
||||
this.recordRoute(sessionId, provider, process?.incarnationId)
|
||||
return { kind: 'owner', provider }
|
||||
}
|
||||
if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
|
||||
private inventory(allowCached = true): Promise<OwnerInventory<T>> {
|
||||
if (this.inventoryInFlight) {
|
||||
return this.inventoryInFlight
|
||||
}
|
||||
if (
|
||||
allowCached &&
|
||||
this.cachedInventory?.value.epoch === this.epoch &&
|
||||
this.cachedInventory.expiresAt > Date.now()
|
||||
) {
|
||||
return Promise.resolve(this.cachedInventory.value)
|
||||
}
|
||||
this.cachedInventory = null
|
||||
const deadlineMs = Date.now() + OWNER_RESOLUTION_TIMEOUT_MS
|
||||
const epoch = this.epoch
|
||||
const inventory = Promise.all(
|
||||
this.providers.map(async (provider): Promise<ProviderInventory<T>> => {
|
||||
if ((this.failedProviderCooldowns.get(provider) ?? 0) > Date.now()) {
|
||||
return { provider, processes: null }
|
||||
}
|
||||
this.failedProviderCooldowns.delete(provider)
|
||||
try {
|
||||
return { provider, processes: await provider.listProcesses({ deadlineMs }) }
|
||||
} catch {
|
||||
if (epoch === this.epoch) {
|
||||
this.failedProviderCooldowns.set(provider, Date.now() + FAILED_PROVIDER_COOLDOWN_MS)
|
||||
}
|
||||
return { provider, processes: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
.then((entries) => this.indexInventory(entries, epoch))
|
||||
.finally(() => {
|
||||
if (this.inventoryInFlight === inventory) {
|
||||
this.inventoryInFlight = null
|
||||
}
|
||||
})
|
||||
this.inventoryInFlight = inventory
|
||||
return inventory
|
||||
}
|
||||
|
||||
private indexInventory(entries: ProviderInventory<T>[], epoch: number): OwnerInventory<T> {
|
||||
const candidatesBySessionId = new Map<string, { provider: T; process: PtyProcessInfo }[]>()
|
||||
let complete = true
|
||||
for (const entry of entries) {
|
||||
if (!entry.processes) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
for (const process of entry.processes) {
|
||||
const candidates = candidatesBySessionId.get(process.id) ?? []
|
||||
candidates.push({ provider: entry.provider, process })
|
||||
candidatesBySessionId.set(process.id, candidates)
|
||||
}
|
||||
}
|
||||
if (complete && epoch === this.epoch) {
|
||||
for (const [sessionId, candidates] of candidatesBySessionId) {
|
||||
const providers = new Set(candidates.map(({ provider }) => provider))
|
||||
if (providers.size === 1) {
|
||||
const provider = providers.values().next().value!
|
||||
const process = candidates.find((candidate) => candidate.provider === provider)?.process
|
||||
this.recordRoute(sessionId, provider, process?.incarnationId)
|
||||
} else {
|
||||
this.forgetRoute(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { candidatesBySessionId, complete, epoch }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer, connect, type Server } from 'node:net'
|
||||
import {
|
||||
DaemonSpawner,
|
||||
getDaemonPidPath,
|
||||
getDaemonSocketPath,
|
||||
getDaemonTokenPath,
|
||||
publishDaemonPidFile,
|
||||
replaceDaemonPidFile,
|
||||
restoreClaimedDaemonArtifact
|
||||
} from './daemon-spawner'
|
||||
import {
|
||||
getDaemonSocketBindPath,
|
||||
publishDaemonSocketPath,
|
||||
sweepAbandonedDaemonClaims,
|
||||
unlinkOwnedDaemonSocketPath
|
||||
} from './daemon-endpoint-ownership'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { DaemonClient } from './client'
|
||||
import type { SubprocessHandle } from './session'
|
||||
@@ -235,3 +245,214 @@ describe('restoreClaimedDaemonArtifact', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('daemon PID publication', () => {
|
||||
it('publishes ownership exclusively', () => {
|
||||
const dir = createTestDir()
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
try {
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: 101,
|
||||
startedAtMs: 1_000,
|
||||
launchNonce: 'launch-a'
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
publishDaemonPidFile(pidPath, {
|
||||
pid: 202,
|
||||
startedAtMs: 2_000,
|
||||
launchNonce: 'launch-b'
|
||||
})
|
||||
).toThrow()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('atomically replaces stale ownership with the authenticated endpoint identity', () => {
|
||||
const dir = createTestDir()
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
const endpointIdentity = {
|
||||
pid: 202,
|
||||
startedAtMs: 2_000,
|
||||
launchNonce: 'launch-b'
|
||||
}
|
||||
try {
|
||||
writeFileSync(pidPath, '{"pid":101,"launchNonce":"launch-a"}')
|
||||
|
||||
expect(replaceDaemonPidFile(pidPath, endpointIdentity)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual(endpointIdentity)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports failure and preserves the record when the rename claim fails for any reason but absence', () => {
|
||||
// A read-only parent denies the rename with EACCES, standing in for the Windows
|
||||
// AV/indexer lock that fails the claim on a record which is merely open.
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
return
|
||||
}
|
||||
const dir = createTestDir()
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
const existingRecord = '{"pid":101,"launchNonce":"launch-a"}'
|
||||
writeFileSync(pidPath, existingRecord)
|
||||
try {
|
||||
chmodSync(dir, 0o500)
|
||||
|
||||
expect(
|
||||
replaceDaemonPidFile(pidPath, { pid: 202, startedAtMs: 2_000, launchNonce: 'launch-b' })
|
||||
).toBe(false)
|
||||
expect(readFileSync(pidPath, 'utf8')).toBe(existingRecord)
|
||||
} finally {
|
||||
chmodSync(dir, 0o700)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function listenOnSocketPath(server: Server, socketPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(socketPath, () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function closeSocketServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!server.listening) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
server.close(() => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
function connectsToSocketPath(socketPath: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
}, 500)
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('daemon socket publication', () => {
|
||||
it('keeps the bind name shorter than the canonical endpoint', () => {
|
||||
// sockaddr_un caps the path, so the private bind name must never extend it.
|
||||
const canonicalPath = getDaemonSocketPath('/tmp/orca-daemon-runtime')
|
||||
|
||||
expect(getDaemonSocketBindPath(canonicalPath).length).toBeLessThan(canonicalPath.length)
|
||||
})
|
||||
|
||||
it('publishes a bound listener under the canonical endpoint and reclaims only its own entry', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const dir = createTestDir()
|
||||
const canonicalPath = getDaemonSocketPath(dir)
|
||||
const first = createServer((socket) => socket.end())
|
||||
const second = createServer((socket) => socket.end())
|
||||
try {
|
||||
const firstBindPath = getDaemonSocketBindPath(canonicalPath)
|
||||
await listenOnSocketPath(first, firstBindPath)
|
||||
|
||||
const firstIdentity = publishDaemonSocketPath(firstBindPath, canonicalPath)
|
||||
expect(firstIdentity).not.toBeNull()
|
||||
expect(existsSync(canonicalPath)).toBe(true)
|
||||
expect(existsSync(firstBindPath)).toBe(false)
|
||||
await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true)
|
||||
|
||||
const secondBindPath = getDaemonSocketBindPath(canonicalPath)
|
||||
await listenOnSocketPath(second, secondBindPath)
|
||||
let publishError: NodeJS.ErrnoException | null = null
|
||||
try {
|
||||
publishDaemonSocketPath(secondBindPath, canonicalPath)
|
||||
} catch (error) {
|
||||
publishError = error as NodeJS.ErrnoException
|
||||
}
|
||||
expect(publishError?.code).toBe('EEXIST')
|
||||
|
||||
expect(unlinkOwnedDaemonSocketPath(canonicalPath, firstIdentity)).toBe(true)
|
||||
expect(existsSync(canonicalPath)).toBe(false)
|
||||
|
||||
// The endpoint name now resolves to a different listener's inode.
|
||||
const secondIdentity = publishDaemonSocketPath(secondBindPath, canonicalPath)
|
||||
expect(secondIdentity).not.toEqual(firstIdentity)
|
||||
expect(unlinkOwnedDaemonSocketPath(canonicalPath, firstIdentity)).toBe(false)
|
||||
expect(existsSync(canonicalPath)).toBe(true)
|
||||
await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true)
|
||||
} finally {
|
||||
await closeSocketServer(first)
|
||||
await closeSocketServer(second)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepAbandonedDaemonClaims', () => {
|
||||
const claimNames = [
|
||||
`daemon-v${PROTOCOL_VERSION}.pid.cleanup-123-${randomUUID()}`,
|
||||
`daemon-v${PROTOCOL_VERSION}.pid.replace-123-${randomUUID()}`,
|
||||
'.b0123456789'
|
||||
]
|
||||
const preservedNames = [`daemon-v${PROTOCOL_VERSION}.pid`, `daemon-v${PROTOCOL_VERSION}.token`]
|
||||
|
||||
function seedClaimDir(): string {
|
||||
const dir = createTestDir()
|
||||
for (const name of [...claimNames, ...preservedNames]) {
|
||||
writeFileSync(join(dir, name), 'x')
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
it('removes aged claim and bind scratch names without touching daemon artifacts', () => {
|
||||
const dir = seedClaimDir()
|
||||
try {
|
||||
expect(sweepAbandonedDaemonClaims(dir, undefined, Date.now() + 24 * 60 * 60 * 1000)).toBe(
|
||||
claimNames.length
|
||||
)
|
||||
|
||||
for (const name of claimNames) {
|
||||
expect(existsSync(join(dir, name))).toBe(false)
|
||||
}
|
||||
for (const name of preservedNames) {
|
||||
expect(existsSync(join(dir, name))).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves freshly written claims alone so an in-flight claim is never stolen', () => {
|
||||
const dir = seedClaimDir()
|
||||
try {
|
||||
expect(sweepAbandonedDaemonClaims(dir)).toBe(0)
|
||||
|
||||
for (const name of [...claimNames, ...preservedNames]) {
|
||||
expect(existsSync(join(dir, name))).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns zero for an unreadable runtime dir', () => {
|
||||
expect(sweepAbandonedDaemonClaims(join(tmpdir(), `daemon-sweep-missing-${randomUUID()}`))).toBe(
|
||||
0
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs'
|
||||
import {
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
|
||||
@@ -113,6 +121,56 @@ export function serializeDaemonPidFile(pidFile: DaemonPidFile): string {
|
||||
return JSON.stringify(pidFile)
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
export function publishDaemonPidFile(pidPath: string, pidFile: DaemonPidFile): void {
|
||||
writeFileSync(pidPath, serializeDaemonPidFile(pidFile), {
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceDaemonPidFile(pidPath: string, pidFile: DaemonPidFile): boolean {
|
||||
const claimedPath = `${pidPath}.replace-${process.pid}-${randomUUID()}`
|
||||
let claimedExisting = false
|
||||
try {
|
||||
renameSync(pidPath, claimedPath)
|
||||
claimedExisting = true
|
||||
} catch (error) {
|
||||
// Why: only a genuinely absent record is safe to treat as unclaimed. On Windows an
|
||||
// external opener without FILE_SHARE_DELETE (AV, indexer, backup) fails the rename
|
||||
// with EPERM/EACCES/EBUSY; falling through would then hit EEXIST on the exclusive
|
||||
// publish and report a false ownership conflict for a record that is simply locked.
|
||||
if (!isMissingFileError(error)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
publishDaemonPidFile(pidPath, pidFile)
|
||||
} catch {
|
||||
if (claimedExisting && restoreClaimedDaemonArtifact(claimedPath, pidPath)) {
|
||||
try {
|
||||
unlinkSync(claimedPath)
|
||||
} catch {
|
||||
// A uniquely named restored claim is inert.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (claimedExisting) {
|
||||
try {
|
||||
unlinkSync(claimedPath)
|
||||
} catch {
|
||||
// The canonical record is authoritative; the uniquely named claim is inert.
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function unlinkOwnedDaemonPidFile(
|
||||
pidPath: string,
|
||||
expectedPid: number,
|
||||
@@ -120,7 +178,10 @@ export function unlinkOwnedDaemonPidFile(
|
||||
): boolean {
|
||||
return claimAndUnlinkOwnedFile(pidPath, (content) => {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { pid?: unknown; launchNonce?: unknown }
|
||||
const parsed = JSON.parse(content) as {
|
||||
pid?: unknown
|
||||
launchNonce?: unknown
|
||||
}
|
||||
return parsed.pid === expectedPid && parsed.launchNonce === expectedLaunchNonce
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
|
||||
|
||||
export class DegradedDaemonOwnerRecovery {
|
||||
private readonly attachResolver: DaemonSessionOwnerResolver<IPtyProvider>
|
||||
private readonly livenessResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
|
||||
|
||||
constructor(
|
||||
providers: readonly IPtyProvider[],
|
||||
private readonly daemonAdapters: readonly DaemonPtyAdapter[],
|
||||
routes: Map<string, IPtyProvider>,
|
||||
private readonly spawnFresh: (opts: PtySpawnOptions) => Promise<PtySpawnResult>
|
||||
) {
|
||||
this.attachResolver = new DaemonSessionOwnerResolver(providers, routes)
|
||||
this.livenessResolver = new DaemonSessionOwnerResolver(daemonAdapters, routes)
|
||||
}
|
||||
|
||||
spawn = async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
|
||||
return opts.attachOnly && opts.sessionId
|
||||
? await this.attachResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
|
||||
: await this.spawnFresh(opts)
|
||||
}
|
||||
|
||||
probe = async (sessionId: string): Promise<boolean | null> =>
|
||||
await this.livenessResolver.probe(sessionId)
|
||||
|
||||
discoverRoutes = async (): Promise<void> => await this.attachResolver.discoverRoutes()
|
||||
|
||||
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
|
||||
alive: string[]
|
||||
killed: string[]
|
||||
}> {
|
||||
const alive: string[] = []
|
||||
const killed: string[] = []
|
||||
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
|
||||
for (const adapter of this.daemonAdapters) {
|
||||
const result = await adapter.reconcileOnStartup(validWorktreeIds)
|
||||
for (const id of result.alive) {
|
||||
alive.push(id)
|
||||
const providers = aliveProviders.get(id) ?? new Set<DaemonPtyAdapter>()
|
||||
providers.add(adapter)
|
||||
aliveProviders.set(id, providers)
|
||||
}
|
||||
killed.push(...result.killed)
|
||||
}
|
||||
for (const id of new Set([...alive, ...killed])) {
|
||||
const providers = aliveProviders.get(id)
|
||||
if (providers?.size === 1) {
|
||||
this.recordRoute(id, providers.values().next().value!)
|
||||
} else {
|
||||
this.forgetRoute(id)
|
||||
}
|
||||
}
|
||||
return { alive, killed }
|
||||
}
|
||||
|
||||
recordRoute(sessionId: string, provider: IPtyProvider): void {
|
||||
this.attachResolver.recordRoute(sessionId, provider)
|
||||
if (this.daemonAdapters.includes(provider as DaemonPtyAdapter)) {
|
||||
this.livenessResolver.recordRoute(sessionId, provider as DaemonPtyAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
forgetRoute(sessionId: string): void {
|
||||
this.attachResolver.forgetRoute(sessionId)
|
||||
this.livenessResolver.forgetRoute(sessionId)
|
||||
}
|
||||
|
||||
subscribeIdentityChanges(): (() => void)[] {
|
||||
return this.daemonAdapters.flatMap((adapter) =>
|
||||
typeof adapter.onDaemonIdentityChanged === 'function'
|
||||
? [
|
||||
adapter.onDaemonIdentityChanged(() => {
|
||||
this.attachResolver.invalidateProvider(adapter)
|
||||
this.livenessResolver.invalidateProvider(adapter)
|
||||
})
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
|
||||
type ProviderMock = IPtyProvider & {
|
||||
probePtyLiveness: (id: string) => Promise<boolean | null>
|
||||
@@ -158,6 +159,47 @@ it('forwards dead-endpoint write-unavailable signals from the daemon adapters',
|
||||
expect(recovered).toEqual(['daemon-pane', 'legacy-pane'])
|
||||
})
|
||||
|
||||
it('routes attach-only to a legacy session created after startup inventory', async () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const legacySessions = ['legacy-at-startup']
|
||||
const legacy = createDaemonAdapter('legacy', legacySessions)
|
||||
const fallback = createProvider('fallback')
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current,
|
||||
legacy: [legacy],
|
||||
fallback
|
||||
})
|
||||
await provider.discoverDaemonSessions()
|
||||
legacySessions.push('legacy-created-later')
|
||||
|
||||
await provider.spawn({
|
||||
sessionId: 'legacy-created-later',
|
||||
attachOnly: true,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(legacy.spawn).toHaveBeenCalledOnce()
|
||||
expect(current.spawn).not.toHaveBeenCalled()
|
||||
expect(fallback.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an attach unresolved when a legacy inventory listing fails', async () => {
|
||||
const current = createDaemonAdapter('daemon')
|
||||
const legacy = createDaemonAdapter('legacy')
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged'))
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current,
|
||||
legacy: [legacy],
|
||||
fallback: createProvider('fallback')
|
||||
})
|
||||
|
||||
await expect(
|
||||
provider.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 })
|
||||
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
|
||||
expect(current.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects completion inspection instead of borrowing the fallback provider', async () => {
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: createDaemonAdapter('daemon'),
|
||||
@@ -368,7 +410,8 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
})
|
||||
|
||||
it('probes daemon owners without borrowing fallback liveness', async () => {
|
||||
const current = createDaemonAdapter('current')
|
||||
const currentSessions: string[] = []
|
||||
const current = createDaemonAdapter('current', currentSessions)
|
||||
const legacy = createDaemonAdapter('legacy')
|
||||
const fallback = createProvider('fallback', ['unknown-session'])
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback })
|
||||
@@ -377,7 +420,7 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull()
|
||||
expect(fallback.probePtyLiveness).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(current.probePtyLiveness).mockResolvedValue(true)
|
||||
currentSessions.push('unknown-session')
|
||||
await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
@@ -529,6 +572,6 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
await expect(provider.listProcesses()).rejects.toThrow('legacy exited')
|
||||
expect(provider.getLegacyAdapters()).toEqual([legacy])
|
||||
expect(current.listProcesses).toHaveBeenCalledTimes(3)
|
||||
expect(fallback.listProcesses).toHaveBeenCalledTimes(2)
|
||||
expect(fallback.listProcesses).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,12 +14,11 @@ import type {
|
||||
import {
|
||||
adoptOwningProvider,
|
||||
attachDaemonOwnedSession,
|
||||
discoverDegradedDaemonSessions,
|
||||
findDaemonAdapter,
|
||||
listProviderSessionIds
|
||||
} from './degraded-daemon-session-routing'
|
||||
import { probePtyOwners } from './daemon-pty-liveness-probe'
|
||||
import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing'
|
||||
import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery'
|
||||
|
||||
export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
readonly isDegraded = true
|
||||
@@ -29,6 +28,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
private fallback: IPtyProvider
|
||||
private sessionProviders = new Map<string, IPtyProvider>()
|
||||
private freshSpawns: DegradedDaemonFreshSpawnRouter
|
||||
private ownerRecovery: DegradedDaemonOwnerRecovery
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: PtyDataEvent) => void)[] = []
|
||||
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
@@ -48,26 +48,27 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
this.sessionProviders,
|
||||
opts.probeCurrentDaemonSpawn ?? null
|
||||
)
|
||||
this.ownerRecovery = new DegradedDaemonOwnerRecovery(
|
||||
this.allProviders(),
|
||||
this.allDaemonAdapters(),
|
||||
this.sessionProviders,
|
||||
(spawnOpts) => this.freshSpawns.spawn(spawnOpts)
|
||||
)
|
||||
|
||||
for (const provider of this.allProviders()) {
|
||||
this.unsubscribers.push(
|
||||
provider.onData((payload) => {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
}),
|
||||
provider.onData((payload) => this.dataListeners.forEach((listener) => listener(payload))),
|
||||
provider.onExit((payload) => {
|
||||
this.sessionProviders.delete(payload.id)
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
this.ownerRecovery.forgetRoute(payload.id)
|
||||
this.exitListeners.forEach((listener) => listener(payload))
|
||||
})
|
||||
)
|
||||
}
|
||||
this.unsubscribers.push(...this.ownerRecovery.subscribeIdentityChanges())
|
||||
}
|
||||
|
||||
discoverDaemonSessions(): Promise<void> {
|
||||
return discoverDegradedDaemonSessions(this.allDaemonAdapters(), this.sessionProviders)
|
||||
async discoverDaemonSessions(): Promise<void> {
|
||||
await this.ownerRecovery.discoverRoutes()
|
||||
}
|
||||
|
||||
get routesFreshSpawnsToLocalProvider(): true | undefined {
|
||||
@@ -82,7 +83,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
canProvideAuthoritativeBufferSnapshot = (id: string): boolean =>
|
||||
this.freshSpawns.canProvideSnapshot(id)
|
||||
|
||||
spawn = (opts: PtySpawnOptions): Promise<PtySpawnResult> => this.freshSpawns.spawn(opts)
|
||||
spawn = (opts: PtySpawnOptions): Promise<PtySpawnResult> => this.ownerRecovery.spawn(opts)
|
||||
|
||||
// Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession.
|
||||
attach = (id: string): Promise<void> =>
|
||||
@@ -94,7 +95,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
}
|
||||
|
||||
async probePtyLiveness(id: string): Promise<boolean | null> {
|
||||
return await probePtyOwners(id, this.sessionProviders.get(id), this.allDaemonAdapters())
|
||||
return await this.ownerRecovery.probe(id)
|
||||
}
|
||||
|
||||
// Why: an unknown id cannot borrow listing authority from the fresh-spawn provider.
|
||||
@@ -275,20 +276,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
alive: string[]
|
||||
killed: string[]
|
||||
}> {
|
||||
const alive: string[] = []
|
||||
const killed: string[] = []
|
||||
for (const adapter of this.allDaemonAdapters()) {
|
||||
const result = await adapter.reconcileOnStartup(validWorktreeIds)
|
||||
for (const id of result.alive) {
|
||||
alive.push(id)
|
||||
this.sessionProviders.set(id, adapter)
|
||||
}
|
||||
for (const id of result.killed) {
|
||||
killed.push(id)
|
||||
this.sessionProviders.delete(id)
|
||||
}
|
||||
}
|
||||
return { alive, killed }
|
||||
return await this.ownerRecovery.reconcileOnStartup(validWorktreeIds)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -2,21 +2,6 @@ import type { IPtyProvider } from '../providers/types'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { SessionNotFoundError } from './daemon-errors'
|
||||
|
||||
export async function discoverDegradedDaemonSessions(
|
||||
adapters: readonly DaemonPtyAdapter[],
|
||||
sessionProviders: Map<string, IPtyProvider>
|
||||
): Promise<void> {
|
||||
for (const adapter of adapters) {
|
||||
try {
|
||||
for (const session of await adapter.listProcesses()) {
|
||||
sessionProviders.set(session.id, adapter)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[daemon] Failed to discover degraded daemon sessions', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listProviderSessionIds(
|
||||
sessionProviders: ReadonlyMap<string, IPtyProvider>,
|
||||
provider: IPtyProvider
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createProductionLauncher } from './production-launcher'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { DaemonClient } from './client'
|
||||
@@ -148,18 +148,18 @@ describe('createProductionLauncher', () => {
|
||||
expect(handlers.exit).toHaveLength(0)
|
||||
expect(child.disconnect).toHaveBeenCalled()
|
||||
expect(child.unref).toHaveBeenCalled()
|
||||
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: 123_456,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a',
|
||||
entryPath: join(dir, 'daemon-entry.js'),
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: 'launch-a'
|
||||
})
|
||||
expect(forkMock).toHaveBeenCalledWith(
|
||||
join(dir, 'daemon-entry.js'),
|
||||
expect.arrayContaining(['--pid-record', pidPath, '--launch-nonce', 'launch-a']),
|
||||
expect.arrayContaining([
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
'launch-a',
|
||||
'--entry-path',
|
||||
join(dir, 'daemon-entry.js'),
|
||||
'--app-version',
|
||||
'1.2.3'
|
||||
]),
|
||||
expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] })
|
||||
)
|
||||
})
|
||||
@@ -208,7 +208,9 @@ describe('createProductionLauncher', () => {
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' }))
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown')
|
||||
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
|
||||
const handle = await launch
|
||||
|
||||
@@ -216,6 +218,7 @@ describe('createProductionLauncher', () => {
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(child.exitCode).toBe(0)
|
||||
expect(existsSync(pidPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => {
|
||||
@@ -337,7 +340,7 @@ describe('createProductionLauncher', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves PID publication and cleanup failures', async () => {
|
||||
it('preserves exact PID ownership when child exit cannot be confirmed', async () => {
|
||||
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
||||
message: [],
|
||||
error: [],
|
||||
@@ -370,24 +373,65 @@ describe('createProductionLauncher', () => {
|
||||
unref: vi.fn()
|
||||
}
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
const pidPath = join(dir, 'occupied.pid')
|
||||
writeFileSync(pidPath, 'occupied')
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' }))
|
||||
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b')
|
||||
handlers.message[0]?.({ type: 'ready' })
|
||||
|
||||
const error = await launch.catch((caught: unknown) => caught)
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toEqual([
|
||||
expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
|
||||
signalError
|
||||
])
|
||||
expect(child.disconnect).toHaveBeenCalledOnce()
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
expect(existsSync(pidPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes exact child-side PID ownership after a pre-ready exit', async () => {
|
||||
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
||||
message: [],
|
||||
error: [],
|
||||
exit: []
|
||||
}
|
||||
const child = {
|
||||
pid: 12345,
|
||||
connected: true,
|
||||
exitCode: null as number | null,
|
||||
signalCode: null as NodeJS.Signals | null,
|
||||
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
|
||||
handlers[event]?.push(cb)
|
||||
return child
|
||||
}),
|
||||
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
||||
return child
|
||||
}),
|
||||
disconnect: vi.fn(() => {
|
||||
child.connected = false
|
||||
}),
|
||||
unref: vi.fn()
|
||||
}
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' }))
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b')
|
||||
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c')
|
||||
child.exitCode = 1
|
||||
handlers.exit[0]?.(1)
|
||||
|
||||
const error = await launch.catch((caught: unknown) => caught)
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toEqual([
|
||||
expect.objectContaining({ code: 'EEXIST' }),
|
||||
signalError
|
||||
])
|
||||
expect(child.disconnect).toHaveBeenCalledOnce()
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1')
|
||||
expect(existsSync(pidPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import {
|
||||
serializeDaemonPidFile,
|
||||
unlinkOwnedDaemonPidFile,
|
||||
type DaemonLauncher,
|
||||
type DaemonProcessHandle
|
||||
} from './daemon-spawner'
|
||||
@@ -28,6 +27,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
}
|
||||
const entryPath = opts.getDaemonEntryPath()
|
||||
|
||||
const appVersion = opts.getAppVersion()
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
@@ -35,7 +35,18 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
...(pidPath && launchNonce ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] : [])
|
||||
...(pidPath && launchNonce
|
||||
? [
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
appVersion
|
||||
]
|
||||
: [])
|
||||
],
|
||||
{
|
||||
// Why: detached daemon output is not consumed; ignored streams cannot
|
||||
@@ -47,31 +58,18 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
}
|
||||
)
|
||||
|
||||
let readyIdentity: DaemonReadyIdentity
|
||||
try {
|
||||
readyIdentity = await waitForReady(child)
|
||||
await waitForReady(child)
|
||||
} catch (error) {
|
||||
return rejectAfterChildCleanup(child, error)
|
||||
return rejectAfterChildCleanup(child, error, pidPath, launchNonce)
|
||||
}
|
||||
if (pidPath && launchNonce) {
|
||||
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) {
|
||||
return rejectAfterChildCleanup(child, new Error('Daemon readiness identity is incomplete'))
|
||||
}
|
||||
try {
|
||||
writeFileSync(
|
||||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid as number,
|
||||
...readyIdentity,
|
||||
entryPath,
|
||||
appVersion: opts.getAppVersion(),
|
||||
launchNonce
|
||||
}),
|
||||
{ mode: 0o600, flag: 'wx' }
|
||||
)
|
||||
} catch (error) {
|
||||
return rejectAfterChildCleanup(child, error)
|
||||
}
|
||||
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) {
|
||||
return rejectAfterChildCleanup(
|
||||
child,
|
||||
new Error('Daemon readiness identity is incomplete'),
|
||||
pidPath,
|
||||
launchNonce
|
||||
)
|
||||
}
|
||||
|
||||
// Unref so the Electron process can exit without waiting for the daemon
|
||||
@@ -79,7 +77,10 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
child.disconnect()
|
||||
|
||||
return {
|
||||
shutdown: () => shutdownChild(child)
|
||||
shutdown: async () => {
|
||||
await shutdownChild(child)
|
||||
unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,7 +209,12 @@ async function shutdownChild(child: ChildProcess): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown): Promise<never> {
|
||||
async function rejectAfterChildCleanup(
|
||||
child: ChildProcess,
|
||||
launchError: unknown,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
): Promise<never> {
|
||||
try {
|
||||
await shutdownChild(child)
|
||||
} catch (cleanupError) {
|
||||
@@ -217,9 +223,20 @@ async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown
|
||||
'Daemon launch and child cleanup both failed'
|
||||
)
|
||||
}
|
||||
unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce)
|
||||
throw launchError
|
||||
}
|
||||
|
||||
function unlinkLaunchedDaemonPidFile(
|
||||
child: ChildProcess,
|
||||
pidPath?: string,
|
||||
launchNonce?: string
|
||||
): void {
|
||||
if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) {
|
||||
unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce)
|
||||
}
|
||||
}
|
||||
|
||||
function isNoSuchProcessError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'
|
||||
}
|
||||
|
||||
+177
-29
@@ -14,6 +14,7 @@ import type { TuiAgent } from '../../shared/types'
|
||||
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
|
||||
const isWindowsHost = process.platform === 'win32'
|
||||
const posixOnlyIt = isWindowsHost ? it.skip : it
|
||||
@@ -9009,14 +9010,15 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('adopts an exact persisted owner when the runtime projection is missing', async () => {
|
||||
it('repairs a stale persisted incarnation after exact same-id reattach', async () => {
|
||||
const tabId = 'tab-persisted-owner'
|
||||
const leafId = '88888888-8888-4888-8888-888888888888'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const worktreeId = 'repo-1::/tmp/persisted-owner'
|
||||
let attachAttempt = 0
|
||||
const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => ({
|
||||
id: options.sessionId ?? 'unexpected-fresh-id',
|
||||
incarnationId: 'inc-persisted-owner',
|
||||
incarnationId: attachAttempt++ === 1 ? 'inc-wrong-owner' : 'inc-live-owner',
|
||||
isReattach: options.attachOnly === true,
|
||||
snapshot: 'persisted-owner-output'
|
||||
}))
|
||||
@@ -9067,10 +9069,10 @@ describe('registerPtyHandlers', () => {
|
||||
[tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } }
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[paneKey]: 'inc-persisted-owner'
|
||||
[paneKey]: 'inc-stale-owner'
|
||||
}
|
||||
})),
|
||||
persistPtyBinding: vi.fn()
|
||||
persistPtyBinding: vi.fn(() => true)
|
||||
}
|
||||
|
||||
registerPtyHandlers(
|
||||
@@ -9081,7 +9083,7 @@ describe('registerPtyHandlers', () => {
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const mounted = await handlers.get('pty:spawn')!(null, {
|
||||
const spawnArgs = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp/persisted-owner',
|
||||
@@ -9094,18 +9096,22 @@ describe('registerPtyHandlers', () => {
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: worktreeId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mounted = await handlers.get('pty:spawn')!(null, spawnArgs)
|
||||
|
||||
expect(mounted).toMatchObject({
|
||||
id: 'pty-persisted-owner',
|
||||
incarnationId: 'inc-persisted-owner',
|
||||
incarnationId: 'inc-live-owner',
|
||||
isReattach: true
|
||||
})
|
||||
expect(providerSpawn).toHaveBeenCalledOnce()
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect(providerSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
attachOnly: true,
|
||||
sessionId: 'pty-persisted-owner',
|
||||
expectedIncarnationId: 'inc-stale-owner',
|
||||
expectedIncarnationIsAuthoritative: false,
|
||||
command: undefined
|
||||
})
|
||||
)
|
||||
@@ -9115,10 +9121,50 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledOnce()
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
ptyId: 'pty-persisted-owner',
|
||||
incarnationId: 'inc-live-owner',
|
||||
expectedBinding: {
|
||||
ptyId: 'pty-persisted-owner',
|
||||
incarnationId: 'inc-stale-owner'
|
||||
}
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(
|
||||
mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned')
|
||||
).toHaveLength(1)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
|
||||
store.persistPtyBinding.mockClear()
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow(
|
||||
'terminal_pane_owner_changed'
|
||||
)
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
|
||||
runtime.assertPtyRegistrationAllowed.mockImplementationOnce(() => {
|
||||
throw new Error('agent_session_exited_during_start')
|
||||
})
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow(
|
||||
'agent_session_exited_during_start'
|
||||
)
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(3)
|
||||
expect(providerSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
attachOnly: true,
|
||||
sessionId: 'pty-persisted-owner',
|
||||
expectedIncarnationId: 'inc-live-owner',
|
||||
expectedIncarnationIsAuthoritative: true,
|
||||
command: undefined
|
||||
})
|
||||
)
|
||||
clearProviderPtyState('pty-persisted-owner')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -9295,29 +9341,22 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ label: 'another owner reports it alive', liveness: true },
|
||||
{ label: 'no owner could answer', liveness: null }
|
||||
])('keeps a persisted owner whose absence is unproven ($label)', async ({ liveness }) => {
|
||||
it('keeps a persisted owner when daemon routing is unresolved', async () => {
|
||||
const worktreeId = 'repo-1::/tmp/unproven-owner'
|
||||
const cwd = '/tmp/unproven-owner'
|
||||
const tabId = 'tab-unproven-owner'
|
||||
const leafId = '56565656-5656-4656-8656-565656565656'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
// Why: a degraded router answers unmapped ids from the local fallback, which never
|
||||
// owned this daemon session — the same "Session not found" a truly dead PTY yields.
|
||||
const providerSpawn = vi.fn(
|
||||
async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => {
|
||||
if (options.attachOnly) {
|
||||
throw new Error('Session not found: pty-unproven-owner')
|
||||
throw new TerminalSessionOwnerUnverifiedError('pty-unproven-owner')
|
||||
}
|
||||
return { id: 'pty-fresh-unproven', incarnationId: 'inc-fresh-unproven' }
|
||||
}
|
||||
)
|
||||
const probePtyLiveness = vi.fn(async () => liveness)
|
||||
setLocalPtyProvider({
|
||||
spawn: providerSpawn,
|
||||
probePtyLiveness,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
@@ -9410,7 +9449,6 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
).rejects.toThrow('terminal_pane_owner_unverified')
|
||||
|
||||
expect(probePtyLiveness).toHaveBeenCalledWith('pty-unproven-owner')
|
||||
// The live PTY keeps its pane binding, gets no synthetic exit, and is not duplicated.
|
||||
expect(providerSpawn).toHaveBeenCalledOnce()
|
||||
expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ attachOnly: true })
|
||||
@@ -9420,11 +9458,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(session.tabsByWorktree[worktreeId]).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Why the positive direction needs its own case: the sibling retire tests use providers with
|
||||
// no `probePtyLiveness`, so they skip this guard entirely. Without this, the guard could be
|
||||
// strengthened into a permanent veto — no daemon-backed pane could ever recover from a dead
|
||||
// owner — and every suite would stay green.
|
||||
it('still retires and respawns when a provider proves the owner is absent', async () => {
|
||||
it('still retires and respawns when the routed provider confirms absence', async () => {
|
||||
const worktreeId = 'repo-1::/tmp/proven-absent-owner'
|
||||
const cwd = '/tmp/proven-absent-owner'
|
||||
const tabId = 'tab-proven-absent-owner'
|
||||
@@ -9438,10 +9472,8 @@ describe('registerPtyHandlers', () => {
|
||||
return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' }
|
||||
}
|
||||
)
|
||||
const probePtyLiveness = vi.fn(async () => false)
|
||||
setLocalPtyProvider({
|
||||
spawn: providerSpawn,
|
||||
probePtyLiveness,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
@@ -9532,8 +9564,6 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(probePtyLiveness).toHaveBeenCalledWith('pty-proven-absent-owner')
|
||||
// Proven absence is the one answer that authorizes retirement, so recovery must proceed.
|
||||
expect(mounted).toMatchObject({ id: 'pty-fresh-proven' })
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(2)
|
||||
expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({
|
||||
@@ -9548,6 +9578,124 @@ describe('registerPtyHandlers', () => {
|
||||
expect(store.flushOrThrow).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not poll after the routed provider confirms absence', async () => {
|
||||
const worktreeId = 'repo-1::/tmp/probe-blip-owner'
|
||||
const cwd = '/tmp/probe-blip-owner'
|
||||
const tabId = 'tab-probe-blip-owner'
|
||||
const leafId = '67676767-6767-4767-8767-676767676767'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const providerSpawn = vi.fn(
|
||||
async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => {
|
||||
if (options.attachOnly) {
|
||||
throw new Error('Session not found: pty-probe-blip-owner')
|
||||
}
|
||||
return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' }
|
||||
}
|
||||
)
|
||||
const probePtyLiveness = vi.fn(async () => null)
|
||||
setLocalPtyProvider({
|
||||
spawn: providerSpawn,
|
||||
probePtyLiveness,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
let session = {
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-probe-blip-owner' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf' as const, leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-probe-blip-owner' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' }
|
||||
}
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
setWorkspaceSession: vi.fn((next) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
getFolderWorkspace: vi.fn(() => undefined),
|
||||
getFolderWorkspaces: vi.fn(() => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
getRepos: vi.fn(() => [])
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
resolveTerminalPane: vi.fn(() => {
|
||||
throw new Error('terminal_not_found')
|
||||
}),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'term-probe-blip'),
|
||||
preAllocateHandleForPty: vi.fn(() => 'term-probe-blip'),
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
beginPtyRegistration: vi.fn(),
|
||||
cancelPendingPtyRegistration: vi.fn(),
|
||||
assertPtyRegistrationAllowed: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
seedHeadlessTerminal: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
const mounted = await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd,
|
||||
command: 'codex resume probe-blip-session',
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
env: {
|
||||
ORCA_PANE_KEY: paneKey,
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: worktreeId
|
||||
}
|
||||
})
|
||||
|
||||
expect(probePtyLiveness).not.toHaveBeenCalled()
|
||||
expect(mounted).toMatchObject({ id: 'pty-fresh-probe-blip' })
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(2)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(
|
||||
'pty-probe-blip-owner',
|
||||
0,
|
||||
'inc-probe-blip-owner'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while
|
||||
// persistence has already dropped it. Reading "nothing left to retire" as a competing owner
|
||||
// aborted materialization *after* signalling the exit, which destroyed the pane instead of
|
||||
@@ -9676,7 +9824,7 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(probePtyLiveness).toHaveBeenCalledWith('pty-already-retired-owner')
|
||||
expect(probePtyLiveness).not.toHaveBeenCalled()
|
||||
expect(mounted).toMatchObject({ id: 'pty-fresh-already-retired' })
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(2)
|
||||
expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({
|
||||
|
||||
+103
-25
@@ -93,6 +93,7 @@ import {
|
||||
import { resolveWslSessionContext } from '../daemon/wsl-session-context'
|
||||
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
|
||||
import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
|
||||
import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment'
|
||||
@@ -599,6 +600,9 @@ type StablePaneOwner = {
|
||||
leafId: string
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
hasPersistedBinding?: true
|
||||
persistedIncarnationId?: string
|
||||
runtimeIncarnationId?: string
|
||||
}
|
||||
type StablePaneAdoption = {
|
||||
result: PtySpawnResult
|
||||
@@ -677,13 +681,6 @@ function resolveStablePaneOwner(
|
||||
throw new Error('terminal_pane_owner_host_mismatch')
|
||||
}
|
||||
const runtimeIncarnationId = ptyIncarnationById.get(ptyId)
|
||||
if (
|
||||
runtimeIncarnationId &&
|
||||
persisted?.incarnationId &&
|
||||
runtimeIncarnationId !== persisted.incarnationId
|
||||
) {
|
||||
throw new Error('terminal_pane_owner_conflict')
|
||||
}
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
if (!parsed) {
|
||||
return null
|
||||
@@ -695,7 +692,10 @@ function resolveStablePaneOwner(
|
||||
ptyId,
|
||||
...(runtimeIncarnationId || persisted?.incarnationId
|
||||
? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId }
|
||||
: {})
|
||||
: {}),
|
||||
...(persisted ? { hasPersistedBinding: true as const } : {}),
|
||||
...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}),
|
||||
...(runtimeIncarnationId ? { runtimeIncarnationId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,7 +717,7 @@ function retirePersistedStablePaneOwner(
|
||||
// not a competing owner. Reporting failure here strands the pane after its PTY is proven dead.
|
||||
return true
|
||||
}
|
||||
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) {
|
||||
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) {
|
||||
return false
|
||||
}
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
@@ -726,7 +726,7 @@ function retirePersistedStablePaneOwner(
|
||||
parentTabId: owner.tabId,
|
||||
leafId: owner.leafId,
|
||||
ptyId: owner.ptyId,
|
||||
...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {})
|
||||
...(current.incarnationId ? { incarnationId: current.incarnationId } : {})
|
||||
})
|
||||
if (retired === session) {
|
||||
return false
|
||||
@@ -748,6 +748,47 @@ type StablePaneSpawnContext = {
|
||||
onFreshSpawn?: (result: PtySpawnResult) => void
|
||||
}
|
||||
|
||||
function stablePanePersistenceFence(
|
||||
owner: StablePaneOwner | null
|
||||
): { ptyId: string; incarnationId?: string } | undefined {
|
||||
return owner?.hasPersistedBinding
|
||||
? {
|
||||
ptyId: owner.ptyId,
|
||||
...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
function persistAdmittedStablePaneBinding(args: {
|
||||
store: Store | undefined
|
||||
owner: StablePaneOwner | null
|
||||
result: PtySpawnResult
|
||||
worktreeId: string | undefined
|
||||
startupCwd: string | undefined
|
||||
connectionId: string | null | undefined
|
||||
}): boolean {
|
||||
const expectedBinding = stablePanePersistenceFence(args.owner)
|
||||
if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) {
|
||||
return false
|
||||
}
|
||||
const persisted = args.store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: args.worktreeId,
|
||||
tabId: args.owner.tabId,
|
||||
leafId: args.owner.leafId,
|
||||
ptyId: args.result.id,
|
||||
...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}),
|
||||
...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
|
||||
expectedBinding
|
||||
},
|
||||
args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
|
||||
)
|
||||
if (persisted === false) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function attachStablePaneOwner(
|
||||
args: StablePaneSpawnContext & { owner: StablePaneOwner }
|
||||
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> {
|
||||
@@ -758,6 +799,8 @@ async function attachStablePaneOwner(
|
||||
...spawnOptions,
|
||||
sessionId: owner.ptyId,
|
||||
attachOnly: true,
|
||||
expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId,
|
||||
expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined,
|
||||
isNewSession: undefined,
|
||||
command: undefined,
|
||||
commandDelivery: undefined,
|
||||
@@ -769,25 +812,19 @@ async function attachStablePaneOwner(
|
||||
onPtySpawnCommitted: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof TerminalSessionOwnerUnverifiedError) {
|
||||
throw new Error('terminal_pane_owner_unverified')
|
||||
}
|
||||
if (!isPtyAlreadyGoneError(error)) {
|
||||
throw error
|
||||
}
|
||||
// Why: "Session not found" only proves the provider we asked has no such PTY — and a
|
||||
// degraded router answers unmapped ids from the local fallback, which never owned a
|
||||
// daemon session. Retiring on that would signal exit and delete a live agent's pane
|
||||
// binding. Absence must be proven across every possible owner first; `null` (nobody
|
||||
// could answer) is not absence. Providers without a probe are their own sole owner,
|
||||
// so their refusal stays authoritative.
|
||||
if (provider.probePtyLiveness && (await provider.probePtyLiveness(owner.ptyId)) !== false) {
|
||||
throw new Error('terminal_pane_owner_unverified')
|
||||
}
|
||||
const ownerBeforeRetire = args.resolveOwner?.()
|
||||
if (
|
||||
ownerBeforeRetire &&
|
||||
(ownerBeforeRetire.ptyId !== owner.ptyId ||
|
||||
(ownerBeforeRetire.incarnationId !== undefined &&
|
||||
owner.incarnationId !== undefined &&
|
||||
ownerBeforeRetire.incarnationId !== owner.incarnationId))
|
||||
ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId ||
|
||||
ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding ||
|
||||
ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId)
|
||||
) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
@@ -808,7 +845,9 @@ async function attachStablePaneOwner(
|
||||
if (
|
||||
result.id !== owner.ptyId ||
|
||||
result.isReattach !== true ||
|
||||
(owner.incarnationId !== undefined && result.incarnationId !== owner.incarnationId)
|
||||
(owner.runtimeIncarnationId !== undefined &&
|
||||
result.incarnationId !== owner.runtimeIncarnationId) ||
|
||||
(result.incarnationId === undefined && owner.incarnationId !== undefined)
|
||||
) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
@@ -4680,6 +4719,7 @@ export function registerPtyHandlers(
|
||||
: null
|
||||
let result: PtySpawnResult
|
||||
let stablePaneOwner: StablePaneOwner | null = null
|
||||
let stablePaneBindingPersisted = false
|
||||
let rejectedRegistrationCandidate: PtySpawnResult | null = null
|
||||
let pendingRegistrationPtyId: string | null = null
|
||||
let preparedProvisionalExecutionContext = false
|
||||
@@ -4902,6 +4942,24 @@ export function registerPtyHandlers(
|
||||
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
|
||||
}
|
||||
}
|
||||
try {
|
||||
stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
|
||||
store: hostSessionBinding?.store,
|
||||
owner: stablePaneOwner,
|
||||
result,
|
||||
worktreeId: hostSessionBinding?.worktreeId,
|
||||
startupCwd: cwd,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'terminal_pane_owner_changed') {
|
||||
throw error
|
||||
}
|
||||
console.error('[pty] failed to persist runtime PTY binding after attach:', error)
|
||||
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
|
||||
agentSessionOperationOutcome: 'unknown' as const
|
||||
})
|
||||
}
|
||||
if (result.agentSessionEnsure?.disposition === 'adopted') {
|
||||
const owner = result.agentSessionEnsure.owner
|
||||
ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null)
|
||||
@@ -4969,7 +5027,7 @@ export function registerPtyHandlers(
|
||||
target: codexSelectionTarget,
|
||||
settings: getSettings?.()
|
||||
})
|
||||
if (hostSessionBinding) {
|
||||
if (hostSessionBinding && !stablePaneBindingPersisted) {
|
||||
try {
|
||||
const binding = {
|
||||
worktreeId: hostSessionBinding.worktreeId,
|
||||
@@ -5625,6 +5683,7 @@ export function registerPtyHandlers(
|
||||
let finishTerminalInstall = (): void => {}
|
||||
let result: PtySpawnResult
|
||||
let stablePaneOwner: StablePaneOwner | null = null
|
||||
let stablePaneBindingPersisted = false
|
||||
let rejectedRegistrationCandidate: PtySpawnResult | null = null
|
||||
let pendingRegistrationPtyId: string | null = null
|
||||
let preparedProvisionalExecutionContext = false
|
||||
@@ -6245,6 +6304,24 @@ export function registerPtyHandlers(
|
||||
trustedTerminalHandleEnv.delete(preAllocatedHandle)
|
||||
}
|
||||
}
|
||||
try {
|
||||
stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
|
||||
store,
|
||||
owner: stablePaneOwner,
|
||||
result,
|
||||
worktreeId: args.worktreeId,
|
||||
startupCwd: cwd,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'terminal_pane_owner_changed') {
|
||||
throw error
|
||||
}
|
||||
console.error('[pty] failed to persist PTY binding after attach:', error)
|
||||
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
|
||||
agentSessionOperationOutcome: 'unknown' as const
|
||||
})
|
||||
}
|
||||
spawnTiming.log(result.id, {
|
||||
daemon: isDaemonHostSpawn,
|
||||
reattach: result.isReattach ?? false
|
||||
@@ -6303,7 +6380,8 @@ export function registerPtyHandlers(
|
||||
store &&
|
||||
typeof args.worktreeId === 'string' &&
|
||||
typeof args.tabId === 'string' &&
|
||||
validatedLeafId !== null
|
||||
validatedLeafId !== null &&
|
||||
!stablePaneBindingPersisted
|
||||
) {
|
||||
try {
|
||||
const binding = {
|
||||
|
||||
@@ -9033,6 +9033,221 @@ describe('Store', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reconciles only the incarnation of an unchanged durable PTY binding', async () => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
store.setWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
|
||||
})
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding({
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: 'inc-live',
|
||||
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('inc-live')
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-live'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects competing PTY and incarnation changes during reconciliation', async () => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
store.setWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-current' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-current' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-current' }
|
||||
})
|
||||
|
||||
for (const competing of [
|
||||
{ ptyId: 'pty-replaced', expectedIncarnationId: 'inc-current' },
|
||||
{ ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' }
|
||||
]) {
|
||||
expect(
|
||||
store.persistPtyBinding({
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: competing.ptyId,
|
||||
incarnationId: 'inc-live',
|
||||
expectedBinding: {
|
||||
ptyId: competing.ptyId,
|
||||
incarnationId: competing.expectedIncarnationId
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-current'
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles only the requested execution-host partition', async () => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
const session = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf' as const, leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
|
||||
}
|
||||
store.setWorkspaceSession(structuredClone(session), 'local')
|
||||
store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1')
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: 'inc-remote-live',
|
||||
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
expect(store.getWorkspaceSession('local').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-stale'
|
||||
)
|
||||
expect(store.getWorkspaceSession('ssh:ssh-1').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-remote-live'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'local', hostId: undefined },
|
||||
{ label: 'SSH', hostId: 'ssh:ssh-1' }
|
||||
])(
|
||||
'preserves a reconciled incarnation across a renderer snapshot ($label)',
|
||||
async ({ hostId }) => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
|
||||
},
|
||||
hostId
|
||||
)
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: 'inc-live',
|
||||
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
|
||||
},
|
||||
hostId
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId))
|
||||
delete rendererSnapshot.terminalPtyIncarnationsByPaneKey
|
||||
delete rendererSnapshot.terminalTopologyRevisionByRepoId
|
||||
store.setWorkspaceSession(rendererSnapshot, hostId)
|
||||
|
||||
expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-live'
|
||||
)
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-live'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('rolls back incarnation reconciliation when the durability barrier fails', async () => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
store.setWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' }
|
||||
})
|
||||
vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
store.persistPtyBinding({
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: 'inc-live',
|
||||
expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' }
|
||||
})
|
||||
).toThrow('disk full')
|
||||
expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe(
|
||||
'inc-stale'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession({
|
||||
|
||||
+26
-8
@@ -6621,11 +6621,26 @@ export class Store {
|
||||
ptyId: string
|
||||
incarnationId?: string
|
||||
startupCwd?: string
|
||||
expectedBinding?: { ptyId: string; incarnationId?: string }
|
||||
},
|
||||
hostId?: string | null
|
||||
): void {
|
||||
): boolean {
|
||||
const resolvedHostId = this.resolveHostId(hostId)
|
||||
const session = this.getWorkspaceSession(resolvedHostId)
|
||||
const paneKey = `${args.tabId}:${args.leafId}`
|
||||
if (args.expectedBinding) {
|
||||
const tab = session.tabsByWorktree?.[args.worktreeId]?.find(
|
||||
(candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId
|
||||
)
|
||||
const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId]
|
||||
if (
|
||||
!tab ||
|
||||
boundPtyId !== args.expectedBinding.ptyId ||
|
||||
session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
this.state.workspaceSessionsByHostId = {
|
||||
...this.state.workspaceSessionsByHostId,
|
||||
@@ -6633,15 +6648,17 @@ export class Store {
|
||||
}
|
||||
}
|
||||
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
|
||||
const paneKey = `${args.tabId}:${args.leafId}`
|
||||
const reconciledIncarnation =
|
||||
args.expectedBinding !== undefined &&
|
||||
args.incarnationId !== args.expectedBinding.incarnationId
|
||||
let terminalMembershipChanged = false
|
||||
const advanceTopologyAfterMembershipChange = (): void => {
|
||||
const advanceTopologyFence = (): void => {
|
||||
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
|
||||
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
|
||||
if (!terminalMembershipChanged || currentRevision <= 0) {
|
||||
if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) {
|
||||
return
|
||||
}
|
||||
// Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay.
|
||||
// Why: host-admitted membership or incarnation changes must outrank a stale renderer replay.
|
||||
session.terminalTopologyRevisionByRepoId = {
|
||||
...session.terminalTopologyRevisionByRepoId,
|
||||
[repoId]: currentRevision + 1
|
||||
@@ -6696,14 +6713,14 @@ export class Store {
|
||||
}
|
||||
if (!isTerminalLeafId(args.leafId)) {
|
||||
// Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration.
|
||||
advanceTopologyAfterMembershipChange()
|
||||
advanceTopologyFence()
|
||||
try {
|
||||
this.flushOrThrow()
|
||||
} catch (err) {
|
||||
restoreSession()
|
||||
throw err
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
const layout = session.terminalLayoutsByTabId?.[args.tabId]
|
||||
if (layout) {
|
||||
@@ -6744,13 +6761,14 @@ export class Store {
|
||||
}
|
||||
}
|
||||
}
|
||||
advanceTopologyAfterMembershipChange()
|
||||
advanceTopologyFence()
|
||||
try {
|
||||
this.flushOrThrow()
|
||||
} catch (err) {
|
||||
restoreSession()
|
||||
throw err
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── SSH Targets ────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready'
|
||||
import { removeInheritedNoColor } from '../pty/terminal-color-env'
|
||||
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
|
||||
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { resolvePathEnvKey } from '../pty/windows-environment-path'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
@@ -546,7 +547,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
if (args.attachOnly) {
|
||||
throw new Error(`Session not found: ${args.sessionId ?? ''}`)
|
||||
throw new SessionNotFoundError(args.sessionId ?? '')
|
||||
}
|
||||
const id = allocatePtyId(reattachId ?? undefined)
|
||||
const incarnationId = randomUUID()
|
||||
|
||||
@@ -64,6 +64,10 @@ export type PtySpawnOptions = {
|
||||
isNewSession?: boolean
|
||||
/** Attach the named session atomically or fail without creating a process. */
|
||||
attachOnly?: boolean
|
||||
/** Exact persisted owner expected by an attach-only routing decision. */
|
||||
expectedIncarnationId?: PtyIncarnationId
|
||||
/** True when runtime state makes the expected incarnation a hard attach fence. */
|
||||
expectedIncarnationIsAuthoritative?: boolean
|
||||
/** Why: allows the renderer to request a specific shell for a single new
|
||||
* terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without
|
||||
* changing the user's persistent default shell setting. Only consulted on
|
||||
|
||||
@@ -1293,6 +1293,7 @@ type TerminalCreateOptions = {
|
||||
// CLI is `cursor-agent`). Callers that know the agent name it here instead of
|
||||
// guessing a command, and the runtime builds the configured launch.
|
||||
startupAgent?: TuiAgent
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
@@ -20956,7 +20957,8 @@ export class OrcaRuntimeService {
|
||||
private buildStartupForAgent(
|
||||
repo: Repo,
|
||||
agent: TuiAgent,
|
||||
prompt: string | undefined
|
||||
prompt: string | undefined,
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -20974,12 +20976,15 @@ export class OrcaRuntimeService {
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const sessionOptions = this.toAgentSessionOptions(launchPreferences)
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: prompt ?? '',
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
sessionOptions,
|
||||
sessionOptionsOverrideAgentArgs: Boolean(sessionOptions),
|
||||
platform: agentLaunchPlatform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
@@ -21418,6 +21423,7 @@ export class OrcaRuntimeService {
|
||||
observeSetupCompletion?: boolean
|
||||
createdWithAgent?: TuiAgent
|
||||
startupAgent?: TuiAgent
|
||||
startupLaunchPreferences?: AgentLaunchPreferences
|
||||
startupPrompt?: string
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -21450,7 +21456,12 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const agentStartup =
|
||||
!args.startup && args.startupAgent
|
||||
? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt)
|
||||
? this.buildStartupForAgent(
|
||||
repo,
|
||||
args.startupAgent,
|
||||
args.startupPrompt,
|
||||
args.startupLaunchPreferences
|
||||
)
|
||||
: null
|
||||
const draftStartup =
|
||||
!args.startup && !agentStartup && args.startupDraft
|
||||
@@ -24619,12 +24630,15 @@ export class OrcaRuntimeService {
|
||||
return opts
|
||||
}
|
||||
|
||||
const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences)
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
sessionOptions,
|
||||
sessionOptionsOverrideAgentArgs: Boolean(sessionOptions),
|
||||
platform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
|
||||
@@ -11,6 +11,14 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
import type { WorkerStartInput } from './orchestration-worker-start-schema'
|
||||
import {
|
||||
assertWorkerLaunchPreferencesRuntimeSupported,
|
||||
assertWorkerLaunchPreferencesCreateTerminal,
|
||||
createPendingWorkerLaunchReceipt,
|
||||
resolveFederatedWorkerLaunchReceipt,
|
||||
type OrchestrationWorkerLaunchReceipt
|
||||
} from './orchestration-worker-launch-preferences'
|
||||
import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation'
|
||||
|
||||
export async function startFederatedWorker(args: {
|
||||
params: WorkerStartInput
|
||||
@@ -40,7 +48,13 @@ export async function startFederatedWorker(args: {
|
||||
)
|
||||
}
|
||||
const createsWorktree = worktree === 'new-top-level'
|
||||
validateRemoteWorkerStart(params, createsWorktree)
|
||||
assertWorkerLaunchPreferencesCreateTerminal(params)
|
||||
validateFederatedWorkerStartPlacement(params, createsWorktree)
|
||||
const requestedLaunch = createPendingWorkerLaunchReceipt({
|
||||
agent: isTuiAgent(params.agent) ? params.agent : null,
|
||||
model: params.model,
|
||||
effort: params.effort
|
||||
})
|
||||
|
||||
const server = runtime.resolveOrchestrationWorkerServer(params.on as string)
|
||||
const status = (await runtime.callOrchestrationWorkerServer(
|
||||
@@ -62,6 +76,12 @@ export async function startFederatedWorker(args: {
|
||||
`Connected server ${server.name} does not support orchestration federation.`
|
||||
)
|
||||
}
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
capabilities: status.capabilities,
|
||||
serverName: server.name
|
||||
})
|
||||
const federationProtocolVersion = status.capabilities?.includes(
|
||||
ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY
|
||||
)
|
||||
@@ -81,6 +101,7 @@ export async function startFederatedWorker(args: {
|
||||
baseBranch: params.baseBranch ?? null,
|
||||
terminal: params.terminal ?? null,
|
||||
agent: params.agent ?? null,
|
||||
launch: requestedLaunch,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
setup: setupDecision,
|
||||
setupSource: createsWorktree
|
||||
@@ -122,6 +143,8 @@ export async function startFederatedWorker(args: {
|
||||
: undefined,
|
||||
terminal: params.terminal,
|
||||
agent: params.agent,
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
timeoutMs: params.timeoutMs,
|
||||
devMode: params.devMode
|
||||
},
|
||||
@@ -134,6 +157,11 @@ export async function startFederatedWorker(args: {
|
||||
'The worker server returned a different Dispatch attachment.'
|
||||
)
|
||||
}
|
||||
const launch = resolveFederatedWorkerLaunchReceipt(
|
||||
remote.launch,
|
||||
requestedLaunch,
|
||||
remote.state === 'ready'
|
||||
)
|
||||
if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) {
|
||||
db.updateFederatedDispatchResources({
|
||||
dispatchId: started.dispatch.id,
|
||||
@@ -160,6 +188,7 @@ export async function startFederatedWorker(args: {
|
||||
stage: readyWorker.stage,
|
||||
server: { environmentId: server.environmentId, name: server.name },
|
||||
setup: remote.setup,
|
||||
launch,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
effects: remote.effects ?? [],
|
||||
residualResources: remote.residualResources ?? []
|
||||
@@ -171,7 +200,7 @@ export async function startFederatedWorker(args: {
|
||||
remote.failedStage ?? 'remote_attach',
|
||||
remote.lastError ?? 'The worker server reported an unknown start outcome.'
|
||||
)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name, launch)
|
||||
}
|
||||
const worker = db.failWorkerStart(
|
||||
started.dispatch.id,
|
||||
@@ -188,6 +217,7 @@ export async function startFederatedWorker(args: {
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
setup: remote.setup,
|
||||
launch,
|
||||
effects: remote.effects ?? [],
|
||||
residualResources: remote.residualResources ?? []
|
||||
}
|
||||
@@ -204,12 +234,13 @@ export async function startFederatedWorker(args: {
|
||||
server: { environmentId: server.environmentId, name: server.name },
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
launch: requestedLaunch,
|
||||
effects: [],
|
||||
residualResources: []
|
||||
}
|
||||
}
|
||||
const worker = db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', reason)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name)
|
||||
return federatedUnknownReceipt(worker, task.id, server.name, requestedLaunch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,45 +251,13 @@ type RemoteStartReceipt = {
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setup?: { state: string }
|
||||
launch?: OrchestrationWorkerLaunchReceipt
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
function validateRemoteWorkerStart(params: WorkerStartInput, createsWorktree: boolean): void {
|
||||
if (createsWorktree && (!params.name || !params.repo)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Remote new-top-level requires --name and an explicit --repo from remote discovery.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with remote new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to remote new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) {
|
||||
throw new OrchestrationError(
|
||||
'agent_unconfigured',
|
||||
'A configured --agent is required when remote worker-start creates a terminal.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownRemoteStartFailure(code: string): boolean {
|
||||
return [
|
||||
'invalid_argument',
|
||||
@@ -272,7 +271,8 @@ function isKnownRemoteStartFailure(code: string): boolean {
|
||||
function federatedUnknownReceipt(
|
||||
worker: { dispatch_id: string; state: string; stage: string; last_error: string | null },
|
||||
taskId: string,
|
||||
serverName: string
|
||||
serverName: string,
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
): unknown {
|
||||
return {
|
||||
taskId,
|
||||
@@ -280,6 +280,7 @@ function federatedUnknownReceipt(
|
||||
state: 'outcome_unknown',
|
||||
stage: worker.stage,
|
||||
server: { name: serverName },
|
||||
launch,
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
effects: [],
|
||||
|
||||
@@ -61,7 +61,9 @@ describe('federated worker agent launch', () => {
|
||||
taskSpec: 'remote cursor worker',
|
||||
protocolVersion: 1,
|
||||
worktree: 'id:repo::remote-worktree',
|
||||
agent: 'cursor'
|
||||
agent: 'cursor',
|
||||
model: 'gpt-5.3-codex',
|
||||
effort: 'high'
|
||||
}),
|
||||
{
|
||||
runtime,
|
||||
@@ -72,14 +74,28 @@ describe('federated worker agent launch', () => {
|
||||
payloadHash: 'remote_payload'
|
||||
}
|
||||
}
|
||||
)) as { state: string; failedStage?: string; lastError?: string }
|
||||
)) as {
|
||||
state: string
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
launch: unknown
|
||||
}
|
||||
|
||||
// Why: assert the worker actually reached ready — a spy-only assertion would
|
||||
// stay green even if every stage after terminal_create regressed.
|
||||
expect(result).toMatchObject({ state: 'ready' })
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
launch: {
|
||||
requested: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' },
|
||||
effective: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' }
|
||||
}
|
||||
})
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::remote-worktree',
|
||||
expect.objectContaining({ startupAgent: 'cursor' })
|
||||
expect.objectContaining({
|
||||
startupAgent: 'cursor',
|
||||
launchPreferences: { model: 'gpt-5.3-codex', effort: 'high' }
|
||||
})
|
||||
)
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::remote-worktree',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
import { isFederationEffectUnknown } from './orchestration-federation-effects'
|
||||
import type { WorkerSetupReceipt } from './orchestration-worker-topology'
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences'
|
||||
|
||||
export function failFederatedAttachmentWithReceipt(args: {
|
||||
db: OrchestrationDb
|
||||
@@ -9,6 +10,7 @@ export function failFederatedAttachmentWithReceipt(args: {
|
||||
failedStage: string
|
||||
error: unknown
|
||||
setup: WorkerSetupReceipt
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
}): unknown {
|
||||
const reason = args.error instanceof Error ? args.error.message : String(args.error)
|
||||
const unknown = isFederationEffectUnknown(args.error, args.failedStage)
|
||||
@@ -26,6 +28,7 @@ export function failFederatedAttachmentWithReceipt(args: {
|
||||
failedStage: args.failedStage,
|
||||
lastError: reason,
|
||||
setup: args.setup,
|
||||
launch: args.launch,
|
||||
effects: JSON.parse(attachment.effects) as unknown[],
|
||||
residualResources: JSON.parse(attachment.residual_resources) as unknown[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-schema'
|
||||
|
||||
export const FederationAttachStartParams = z.object({
|
||||
dispatchId: requiredString('Missing Dispatch ID'),
|
||||
@@ -16,6 +17,10 @@ export const FederationAttachStartParams = z.object({
|
||||
setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(),
|
||||
terminal: OptionalString,
|
||||
agent: OptionalString,
|
||||
model: OptionalWorkerLaunchPreference,
|
||||
effort: OptionalWorkerLaunchPreference,
|
||||
timeoutMs: OptionalFiniteNumber,
|
||||
devMode: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type FederationAttachStartInput = z.infer<typeof FederationAttachStartParams>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
|
||||
import type { RpcRequest } from '../core'
|
||||
|
||||
export function createFederationWorkerStartRequest(
|
||||
taskId: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: 'rpc_worker_start',
|
||||
authToken: 'coordinator-token',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: 'request_windows_worker',
|
||||
method: 'orchestration.workerStart',
|
||||
params: {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
repo: 'id:windows-repo',
|
||||
name: 'windows-audit',
|
||||
agent: 'codex',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
import { createFederationWorkerStartRequest as startRequest } from './orchestration-federation-test-request'
|
||||
|
||||
describe('orchestration federation', () => {
|
||||
const databases: OrchestrationDb[] = []
|
||||
@@ -97,26 +97,6 @@ describe('orchestration federation', () => {
|
||||
return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id })
|
||||
}
|
||||
|
||||
function startRequest(taskId: string, overrides: Record<string, unknown> = {}): RpcRequest {
|
||||
return {
|
||||
id: 'rpc_worker_start',
|
||||
authToken: 'coordinator-token',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: 'request_windows_worker',
|
||||
method: 'orchestration.workerStart',
|
||||
params: {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
repo: 'id:windows-repo',
|
||||
name: 'windows-audit',
|
||||
agent: 'codex',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function configureWorkerRuntime(runtime: OrcaRuntimeService): void {
|
||||
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
|
||||
vi.spyOn(runtime, 'showRepo').mockResolvedValue({
|
||||
@@ -230,6 +210,25 @@ describe('orchestration federation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report remotely rejected preferences as effective', async () => {
|
||||
const task = createHomeTask()
|
||||
|
||||
const response = await homeDispatcher.dispatch(
|
||||
startRequest(task.id, { agent: 'grok', model: 'unsupported-model' })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
state: 'failed',
|
||||
launch: {
|
||||
requested: { agent: 'grok', model: 'unsupported-model', effort: null },
|
||||
effective: null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves wait-for-setup gating on the connected worker server', async () => {
|
||||
vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({
|
||||
worktree: { id: 'repo::windows-worktree', repoId: 'repo' },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { buildDispatchPreamble } from '../../orchestration/preamble'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from './orchestration-federation-setup'
|
||||
import { FederationAttachStartParams } from './orchestration-federation-start-schema'
|
||||
import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt'
|
||||
import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation'
|
||||
|
||||
export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -37,43 +37,11 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
)
|
||||
}
|
||||
const createsWorktree = params.worktree === 'new-top-level'
|
||||
if (createsWorktree && (!params.name || !params.repo)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'A remote new-top-level worktree requires --name and an explicit --repo.'
|
||||
)
|
||||
}
|
||||
if (createsWorktree && params.terminal) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal cannot combine with remote new-worktree creation.'
|
||||
)
|
||||
}
|
||||
if (
|
||||
!createsWorktree &&
|
||||
(params.name || params.repo || params.baseBranch || params.setup || params.setupSource)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Creation and setup options apply only to remote new-top-level worktrees.'
|
||||
)
|
||||
}
|
||||
if (params.terminal && params.agent) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--terminal reuses an existing agent and cannot combine with --agent.'
|
||||
)
|
||||
}
|
||||
const agent = params.agent
|
||||
if (!params.terminal && (!agent || !isTuiAgent(agent))) {
|
||||
throw new OrchestrationError(
|
||||
'agent_unconfigured',
|
||||
'A configured --agent is required when federated worker-start creates a terminal.'
|
||||
)
|
||||
}
|
||||
if (agent) {
|
||||
runtime.validateOrchestrationAgentLauncher(agent as TuiAgent)
|
||||
}
|
||||
const { agent, launch } = prepareFederationAttachmentWorkerStart({
|
||||
params,
|
||||
createsWorktree,
|
||||
runtime
|
||||
})
|
||||
if (createsWorktree) {
|
||||
await assertOrchestrationWorktreeCreationSupported({
|
||||
runtime,
|
||||
@@ -126,6 +94,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
observeSetupCompletion: true,
|
||||
createdWithAgent: agent as TuiAgent,
|
||||
startupAgent: agent as TuiAgent,
|
||||
...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}),
|
||||
activate: false,
|
||||
lineage: { noParent: true }
|
||||
})
|
||||
@@ -196,6 +165,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
// Why: agent ids are not shell commands (`cursor` is the desktop app,
|
||||
// its CLI is `cursor-agent`); resolve through the TUI agent config.
|
||||
startupAgent: agent as TuiAgent,
|
||||
...(launch.preferences ? { launchPreferences: launch.preferences } : {}),
|
||||
title: `worker-${params.taskId}`,
|
||||
presentation: 'background'
|
||||
})
|
||||
@@ -284,6 +254,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
worktreeId: worktree.id,
|
||||
terminalHandle,
|
||||
setup,
|
||||
launch: launch.receipt,
|
||||
effects,
|
||||
residualResources: []
|
||||
}
|
||||
@@ -294,7 +265,8 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
runtimeEpoch: runtime.getRuntimeId(),
|
||||
failedStage,
|
||||
error,
|
||||
setup
|
||||
setup,
|
||||
launch: launch.receipt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
assertWorkerLaunchPreferencesCreateTerminal,
|
||||
assertWorkerLaunchPreferencesRuntimeSupported,
|
||||
createPendingWorkerLaunchReceipt,
|
||||
resolveFederatedWorkerLaunchReceipt,
|
||||
resolveWorkerLaunchPreferences
|
||||
} from './orchestration-worker-launch-preferences'
|
||||
import { WorkerStartParams } from './orchestration-worker-start-schema'
|
||||
|
||||
describe('orchestration worker launch preferences', () => {
|
||||
it('passes an opaque Claude model and portable effort through the shared catalog', () => {
|
||||
expect(
|
||||
resolveWorkerLaunchPreferences({
|
||||
agent: 'claude',
|
||||
model: 'aws-bedrock-opus-5',
|
||||
effort: 'high'
|
||||
})
|
||||
).toEqual({
|
||||
preferences: { model: 'aws-bedrock-opus-5', effort: 'high' },
|
||||
receipt: {
|
||||
requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' },
|
||||
effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not invent an effort when only a model is requested', () => {
|
||||
expect(
|
||||
resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences
|
||||
).toEqual({ model: 'gpt-5.6-sol' })
|
||||
})
|
||||
|
||||
it('rejects model-specific effort combinations the catalog disproves', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({
|
||||
agent: 'codex',
|
||||
model: 'gpt-5.6-luna',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
).toThrow('does not support effort xhigh')
|
||||
})
|
||||
|
||||
it('rejects effort without a model', () => {
|
||||
expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow(
|
||||
'--effort requires --model'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects model selection for agents without a launch catalog', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({ agent: 'grok', model: 'grok-code-fast-1' })
|
||||
).toThrow('does not support launch-time model selection')
|
||||
})
|
||||
|
||||
it('does not expose deprecated Gemini model selection to worker-start', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({ agent: 'gemini', model: 'gemini-3-pro-preview' })
|
||||
).toThrow('does not support launch-time model selection')
|
||||
})
|
||||
|
||||
it('rejects preferences when reusing an existing terminal', () => {
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesCreateTerminal({
|
||||
terminal: 'term_existing',
|
||||
model: 'gpt-5.6-sol'
|
||||
})
|
||||
).toThrow('cannot be applied when reusing an existing terminal')
|
||||
})
|
||||
|
||||
it('requires remote capability support only for explicit preferences', () => {
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
model: 'gpt-5.6-sol',
|
||||
capabilities: [],
|
||||
serverName: 'windows'
|
||||
})
|
||||
).toThrow('does not support worker model or effort overrides')
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
capabilities: [],
|
||||
serverName: 'windows'
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesRuntimeSupported({
|
||||
model: 'gpt-5.6-sol',
|
||||
capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY],
|
||||
serverName: 'windows'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses the requested launch receipt when an older worker omits it', () => {
|
||||
const requested = createPendingWorkerLaunchReceipt({
|
||||
agent: 'codex',
|
||||
model: 'gpt-5.6-sol',
|
||||
effort: 'high'
|
||||
})
|
||||
|
||||
expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, true)).toEqual({
|
||||
requested: requested.requested,
|
||||
effective: requested.requested
|
||||
})
|
||||
expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, false)).toBe(requested)
|
||||
})
|
||||
|
||||
it.each([' custom-model', 'custom-model '])(
|
||||
'rejects model ids with surrounding whitespace: %j',
|
||||
(model) => {
|
||||
expect(WorkerStartParams.safeParse({ task: 'task_1', agent: 'codex', model }).success).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('bounds opaque launch preferences', () => {
|
||||
expect(
|
||||
WorkerStartParams.safeParse({
|
||||
task: 'task_1',
|
||||
agent: 'codex',
|
||||
model: 'm'.repeat(513)
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
WorkerStartParams.safeParse({
|
||||
task: 'task_1',
|
||||
agent: 'codex',
|
||||
model: 'custom-model',
|
||||
effort: 'e'.repeat(513)
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority'
|
||||
import {
|
||||
findCatalogModel,
|
||||
findCatalogOption,
|
||||
getAgentSessionOptionCatalog
|
||||
} from '../../../../shared/agent-session-option-catalog'
|
||||
import { resolveAgentSessionOptionLaunch } from '../../../../shared/agent-session-option-launch'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
|
||||
export type OrchestrationWorkerLaunchSelection = {
|
||||
agent: TuiAgent | null
|
||||
model: string | null
|
||||
effort: string | null
|
||||
}
|
||||
|
||||
export type OrchestrationWorkerLaunchReceipt = {
|
||||
requested: OrchestrationWorkerLaunchSelection
|
||||
effective: OrchestrationWorkerLaunchSelection | null
|
||||
}
|
||||
|
||||
export function createWorkerLaunchReceipt(args: {
|
||||
agent: TuiAgent | null
|
||||
model?: string
|
||||
effort?: string
|
||||
}): OrchestrationWorkerLaunchReceipt {
|
||||
const selection = {
|
||||
agent: args.agent,
|
||||
model: args.model ?? null,
|
||||
effort: args.effort ?? null
|
||||
}
|
||||
return { requested: selection, effective: { ...selection } }
|
||||
}
|
||||
|
||||
export function createPendingWorkerLaunchReceipt(args: {
|
||||
agent: TuiAgent | null
|
||||
model?: string
|
||||
effort?: string
|
||||
}): OrchestrationWorkerLaunchReceipt {
|
||||
return {
|
||||
requested: {
|
||||
agent: args.agent,
|
||||
model: args.model ?? null,
|
||||
effort: args.effort ?? null
|
||||
},
|
||||
effective: null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkerLaunchPreferences(args: {
|
||||
agent: TuiAgent
|
||||
model?: string
|
||||
effort?: string
|
||||
}): {
|
||||
preferences: AgentLaunchPreferences | undefined
|
||||
receipt: OrchestrationWorkerLaunchReceipt
|
||||
} {
|
||||
if (args.effort && !args.model) {
|
||||
throw new OrchestrationError('invalid_argument', '--effort requires --model.')
|
||||
}
|
||||
if (!args.model) {
|
||||
return {
|
||||
preferences: undefined,
|
||||
receipt: createWorkerLaunchReceipt({ agent: args.agent })
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = getAgentSessionOptionCatalog(args.agent)
|
||||
if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} does not support launch-time model selection.`
|
||||
)
|
||||
}
|
||||
|
||||
if (args.effort) {
|
||||
const model = findCatalogModel(catalog, args.model)
|
||||
const option =
|
||||
findCatalogOption(model, 'effort') ??
|
||||
(!model
|
||||
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
|
||||
: undefined)
|
||||
if (
|
||||
option?.kind.type !== 'select' ||
|
||||
!option.kind.choices.some((choice) => choice.value === args.effort)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const requested = {
|
||||
model: args.model,
|
||||
...(args.effort ? { effort: args.effort } : {})
|
||||
}
|
||||
const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false)
|
||||
if (
|
||||
resolved.appliedValues.model !== args.model ||
|
||||
resolved.appliedValues.effort !== args.effort
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} cannot apply the requested worker launch preferences.`
|
||||
)
|
||||
}
|
||||
|
||||
const preferences: AgentLaunchPreferences = requested
|
||||
return {
|
||||
preferences,
|
||||
receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences })
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerLaunchPreferencesCreateTerminal(args: {
|
||||
terminal?: string
|
||||
model?: string
|
||||
effort?: string
|
||||
}): void {
|
||||
if (args.terminal && (args.model || args.effort)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--model and --effort cannot be applied when reusing an existing terminal.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerLaunchPreferencesRuntimeSupported(args: {
|
||||
model?: string
|
||||
effort?: string
|
||||
capabilities?: readonly string[]
|
||||
serverName: string
|
||||
}): void {
|
||||
if (
|
||||
(args.model || args.effort) &&
|
||||
!args.capabilities?.includes(ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'capability_unsupported',
|
||||
`Connected server ${args.serverName} does not support worker model or effort overrides.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFederatedWorkerLaunchReceipt(
|
||||
remote: OrchestrationWorkerLaunchReceipt | undefined,
|
||||
requested: OrchestrationWorkerLaunchReceipt,
|
||||
remoteReady: boolean
|
||||
): OrchestrationWorkerLaunchReceipt {
|
||||
if (remote) {
|
||||
return remote
|
||||
}
|
||||
return remoteReady
|
||||
? { requested: requested.requested, effective: { ...requested.requested } }
|
||||
: requested
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isUnknownWorkerStartOutcome,
|
||||
type WorkerSetupReceipt
|
||||
} from './orchestration-worker-topology'
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences'
|
||||
|
||||
export function failWorkerStartWithReceipt(args: {
|
||||
db: OrchestrationDb
|
||||
@@ -12,6 +13,7 @@ export function failWorkerStartWithReceipt(args: {
|
||||
failedStage: string
|
||||
error: unknown
|
||||
setup: WorkerSetupReceipt
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
}): unknown {
|
||||
const reason = args.error instanceof Error ? args.error.message : String(args.error)
|
||||
const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage)
|
||||
@@ -27,6 +29,7 @@ export function failWorkerStartWithReceipt(args: {
|
||||
failedStage: args.failedStage,
|
||||
lastError: reason,
|
||||
setup: args.setup,
|
||||
launch: args.launch,
|
||||
effects: JSON.parse(worker.effects) as unknown[],
|
||||
residualResources: JSON.parse(worker.residual_resources) as unknown[],
|
||||
...(unknown
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
export const OptionalWorkerLaunchPreference = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.refine((value) => value === value.trim(), 'Surrounding whitespace is invalid')
|
||||
.optional()
|
||||
|
||||
export const WorkerStartParams = z.object({
|
||||
task: requiredString('Missing --task'),
|
||||
on: OptionalString,
|
||||
@@ -15,6 +22,8 @@ export const WorkerStartParams = z.object({
|
||||
setup: z.enum(['run', 'skip', 'inherit']).optional(),
|
||||
terminal: OptionalString,
|
||||
agent: OptionalString,
|
||||
model: OptionalWorkerLaunchPreference,
|
||||
effort: OptionalWorkerLaunchPreference,
|
||||
retryOf: OptionalString,
|
||||
timeoutMs: OptionalFiniteNumber,
|
||||
devMode: z.boolean().optional()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user