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:
Brennan Benson
2026-08-05 22:18:19 -07:00
175 changed files with 9864 additions and 1448 deletions
+6
View File
@@ -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
+105 -35
View File
@@ -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')
+6 -3
View File
@@ -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
View File
@@ -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)}
/>
+2 -2
View File
@@ -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]
+3 -12
View File
@@ -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
+27 -1
View File
@@ -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')
})
})
+38 -8
View File
@@ -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: {
+178
View File
@@ -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())
}
})
})
+106 -84
View File
@@ -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))
}
+10 -3
View File
@@ -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
}
}
+328 -3
View File
@@ -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
View File
@@ -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())
})
})
+7
View File
@@ -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)
}
}
+106 -36
View File
@@ -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])
}
+8
View File
@@ -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: {
+19 -1
View File
@@ -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'),
+2
View File
@@ -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',
+5 -1
View File
@@ -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
]
})
)
})
+4 -1
View File
@@ -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.',
+30
View File
@@ -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(
+10 -2
View File
@@ -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'
+3
View File
@@ -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',
+3
View File
@@ -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
+11 -1
View File
@@ -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 })
+25 -3
View File
@@ -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
}
+27
View File
@@ -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([
+53 -4
View File
@@ -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')
+20
View File
@@ -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
)
})
})
+14
View File
@@ -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)
+131 -4
View File
@@ -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()
}
})
})
+112 -19
View File
@@ -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 }
}
+4
View File
@@ -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 = {
+439 -80
View File
@@ -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
View File
@@ -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()
}
+12
View File
@@ -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
}
+115 -4
View File
@@ -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 () => {
+28 -20
View File
@@ -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 }
+21
View File
@@ -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
+8 -3
View File
@@ -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 () => {
+188 -18
View File
@@ -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 }
}
}
+222 -1
View File
@@ -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
)
})
})
+63 -2
View File
@@ -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)
})
})
+17 -29
View File
@@ -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
+69 -25
View File
@@ -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)
})
})
+44 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {
+215
View File
@@ -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
View File
@@ -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 ────────────────────────────────────────────────────
+2 -1
View File
@@ -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
+16 -2
View File
@@ -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