Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9a5a078
fix(windows): serialize worktree terminal teardown
bbingz Jul 11, 2026
22f1837
Merge origin/main into fix/windows-terminal-daemon-worktree-removal
Jul 12, 2026
ee5b793
test(runtime): cover sequential worktree teardown isolation
Jul 12, 2026
525c190
perf(pty): use targeted liveness for verified stops
Jul 12, 2026
70da22d
perf(ssh): target PTY liveness checks
Jul 12, 2026
3d72336
fix(runtime): fence worktree terminal lifecycle
Jul 12, 2026
9ea9f2e
fix(runtime): cover headless worktree PTY teardown
Jul 12, 2026
0c9809e
fix: bound terminal teardown verification
Jul 12, 2026
61d9d2c
fix: drain terminals during orphan cleanup
Jul 12, 2026
9298ed6
Merge remote-tracking branch 'origin/main' into review-pr8284-public-…
Jul 12, 2026
e8ae9b0
Merge remote-tracking branch 'origin/main' into review-pr8284-public-…
Jul 12, 2026
48b3dc9
fix: fail closed on unverified SSH teardown
Jul 12, 2026
60e7cb3
docs(runtime): clarify SSH teardown contract
Jul 12, 2026
4993b8c
fix(ssh): reap full PTY session on teardown
Jul 13, 2026
1599f5a
fix(ssh): support Darwin PTY session teardown
Jul 13, 2026
d412681
fix(terminals): fail closed on incomplete teardown
Jul 13, 2026
8fa167f
fix(terminals): verify bounded teardown to completion
brennanb2025 Jul 13, 2026
5da17e6
Merge remote-tracking branch 'origin/main' into review-pr8284-public-…
brennanb2025 Jul 13, 2026
7dc6aea
fix(terminals): fail closed across remote teardown gaps
brennanb2025 Jul 13, 2026
89544be
Merge remote-tracking branch 'origin/main' into review-pr8284-public-…
brennanb2025 Jul 13, 2026
0e904fa
test(relay): make POSIX teardown coverage deterministic
bbingz Jul 13, 2026
b1e478a
Merge remote-tracking branch 'origin/main' into fix/windows-terminal-…
bbingz Jul 13, 2026
1569ef7
test(relay): await Windows immediate teardown
bbingz Jul 13, 2026
bf1d215
fix(terminals): scope and verify destructive teardown
brennanb2025 Jul 13, 2026
333941d
Merge remote-tracking branch 'origin/main' into review-pr8284-public-…
brennanb2025 Jul 13, 2026
ba8e22a
Merge remote-tracking branch 'pr-bbingz-orca/fix/windows-terminal-dae…
brennanb2025 Jul 13, 2026
a280272
fix(relay): support Node 18 session teardown
brennanb2025 Jul 13, 2026
17eae51
Merge remote-tracking branch 'origin/main' into fix/windows-terminal-…
bbingz Jul 13, 2026
5c81d3c
fix(daemon): tolerate legacy teardown exit race
bbingz Jul 13, 2026
d56eb92
Merge remote-tracking branch 'fork/fix/windows-terminal-daemon-worktr…
bbingz Jul 13, 2026
7666c0e
fix(windows): allow ConPTY teardown verification to settle
bbingz Jul 13, 2026
960ba8c
fix(terminals): harden verified process teardown
brennanb2025 Jul 13, 2026
7c16fa9
Merge remote-tracking branch 'origin/main' into review-pr8284-public-…
brennanb2025 Jul 13, 2026
d9983bf
Merge remote-tracking branch 'pr-bbingz-orca/fix/windows-terminal-dae…
brennanb2025 Jul 13, 2026
244c4a7
test(relay): remove stale teardown compatibility patch
bbingz Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'

describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBeGreaterThan(19)
expect(PROTOCOL_VERSION).toBe(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
// Why: v21 lacks full-session teardown, so the client must replace it
// instead of accepting a legacy daemon that can strand descendants.
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21)
})
})
37 changes: 37 additions & 0 deletions src/main/daemon/daemon-pty-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,43 @@ describe('DaemonPtyRouter', () => {
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})

it('keeps multi-PTY post-shutdown verification O(T) across retained adapters', async () => {
const adapters = Array.from({ length: 21 }, (_, index) => createAdapter(`adapter-${index}`))
const sessionIds = Array.from({ length: 50 }, (_, index) => `session-${index}`)
for (const [index, sessionId] of sessionIds.entries()) {
const adapter = adapters[index % adapters.length]!
await adapter.spawn({ sessionId, cols: 80, rows: 24 })
}
const router = new DaemonPtyRouter({ current: adapters[0]!, legacy: adapters.slice(1) })
await router.discoverLegacySessions()
for (const adapter of adapters) {
vi.mocked(adapter.hasPty).mockClear()
}

for (const sessionId of sessionIds) {
await router.shutdown(sessionId, { immediate: true })
expect(router.hasPty(sessionId)).toBe(false)
}

expect(
adapters.reduce((count, adapter) => count + vi.mocked(adapter.shutdown).mock.calls.length, 0)
).toBe(50)
expect(
adapters.reduce((count, adapter) => count + vi.mocked(adapter.hasPty).mock.calls.length, 0)
).toBe(50)
})

it('invalidates stopped evidence when the same session id spawns again', async () => {
const current = createAdapter('current', ['reused-session'])
const router = new DaemonPtyRouter({ current, legacy: [] })

await router.shutdown('reused-session', { immediate: true })
expect(router.hasPty('reused-session')).toBe(false)
await router.spawn({ sessionId: 'reused-session', cols: 80, rows: 24 })

expect(router.hasPty('reused-session')).toBe(true)
})

it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
Expand Down
19 changes: 18 additions & 1 deletion src/main/daemon/daemon-pty-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import { ShutdownVerificationOwnerCache } from './shutdown-verification-owner-cache'

export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
private legacy: DaemonPtyAdapter[]
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
private shutdownVerificationAdapters = new ShutdownVerificationOwnerCache<DaemonPtyAdapter>()
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: {
id: string
Expand Down Expand Up @@ -47,6 +49,7 @@ export class DaemonPtyRouter implements IPtyProvider {
const sessions = await adapter.listProcesses()
for (const session of sessions) {
this.sessionAdapters.set(session.id, adapter)
this.shutdownVerificationAdapters.delete(session.id)
}
} catch (error) {
console.warn('[daemon] Failed to discover legacy daemon sessions', error)
Expand All @@ -59,14 +62,20 @@ export class DaemonPtyRouter implements IPtyProvider {
const target = adapter ?? this.current
const result = await target.spawn(opts)
this.sessionAdapters.set(result.id, target)
this.shutdownVerificationAdapters.delete(result.id)
return result
}

async attach(id: string): Promise<void> {
await this.adapterFor(id).attach(id)
this.shutdownVerificationAdapters.delete(id)
}

hasPty(id: string): boolean {
const shutdownOwner = this.shutdownVerificationAdapters.take(id)
if (shutdownOwner) {
return shutdownOwner.hasPty(id)
}
const routed = this.sessionAdapters.get(id)
if (routed) {
return routed.hasPty(id)
Expand Down Expand Up @@ -95,7 +104,8 @@ export class DaemonPtyRouter implements IPtyProvider {
}

async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.adapterFor(id).shutdown(id, opts)
const owner = this.adapterFor(id)
await owner.shutdown(id, opts)
// Why: sleep passes keepHistory=true and re-spawns against the same
// sessionId on wake. If we delete the routing entry here, adapterFor()
// falls back to `this.current` on wake — for a session that originally
Expand All @@ -104,6 +114,7 @@ export class DaemonPtyRouter implements IPtyProvider {
// losing the cold-restore from the legacy adapter's history dir.
if (!opts.keepHistory) {
this.sessionAdapters.delete(id)
this.shutdownVerificationAdapters.remember(id, owner)
}
}

Expand Down Expand Up @@ -156,6 +167,9 @@ export class DaemonPtyRouter implements IPtyProvider {

async revive(state: string): Promise<void> {
await this.current.revive(state)
// Why: serialized revive state is opaque here; discard negative evidence
// so a restored id is discovered from its owning adapter instead.
this.shutdownVerificationAdapters.clear()
}

async listProcesses(): Promise<PtyProcessInfo[]> {
Expand Down Expand Up @@ -236,15 +250,18 @@ export class DaemonPtyRouter implements IPtyProvider {
}
for (const id of result.alive) {
this.sessionAdapters.set(id, adapter)
this.shutdownVerificationAdapters.delete(id)
}
for (const id of result.killed) {
this.sessionAdapters.delete(id)
this.shutdownVerificationAdapters.delete(id)
}
}
return { alive, killed }
}

dispose(): void {
this.shutdownVerificationAdapters.clear()
for (const unsubscribe of this.unsubscribers.splice(0)) {
unsubscribe()
}
Expand Down
4 changes: 3 additions & 1 deletion src/main/daemon/daemon-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,9 @@ export class DaemonServer {
sessionId: request.payload.sessionId,
immediate: request.payload.immediate === true
})
this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
await this.host.killAndWait(request.payload.sessionId, {
immediate: request.payload.immediate
})
return {}

case 'signal':
Expand Down
59 changes: 59 additions & 0 deletions src/main/daemon/degraded-daemon-pty-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,65 @@ describe('DegradedDaemonPtyProvider', () => {
expect(fallback.write).not.toHaveBeenCalled()
})

it('keeps multi-PTY post-shutdown verification O(T) across retained providers', async () => {
const adapters = Array.from({ length: 21 }, (_, index) =>
createDaemonAdapter(`adapter-${index}`)
)
const fallback = createProvider('fallback')
const sessionIds = Array.from({ length: 50 }, (_, index) => `session-${index}`)
for (const [index, sessionId] of sessionIds.entries()) {
const adapter = adapters[index % adapters.length]!
await adapter.spawn({ sessionId, cols: 80, rows: 24 })
}
const provider = new DegradedDaemonPtyProvider({
current: adapters[0]!,
legacy: adapters.slice(1),
fallback
})
await provider.discoverDaemonSessions()
for (const adapter of adapters) {
vi.mocked(adapter.hasPty).mockClear()
}
vi.mocked(fallback.hasPty!).mockClear()

for (const sessionId of sessionIds) {
await provider.shutdown(sessionId, { immediate: true })
expect(provider.hasPty(sessionId)).toBe(false)
}

expect(
adapters.reduce((count, adapter) => count + vi.mocked(adapter.shutdown).mock.calls.length, 0)
).toBe(50)
expect(
adapters.reduce((count, adapter) => count + vi.mocked(adapter.hasPty).mock.calls.length, 0)
).toBe(50)
expect(fallback.hasPty).not.toHaveBeenCalled()
})

it('invalidates stopped evidence when the same session id spawns again', async () => {
const current = createDaemonAdapter('daemon')
const fallback = createProvider('fallback', ['reused-session'])
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })

await provider.shutdown('reused-session', { immediate: true })
expect(provider.hasPty('reused-session')).toBe(false)
await provider.spawn({ sessionId: 'reused-session', cols: 80, rows: 24 })

expect(provider.hasPty('reused-session')).toBe(true)
})

it('keeps shutdown verification conservative when the cached owner lacks hasPty', async () => {
const current = createDaemonAdapter('daemon')
const fallback = createProvider('fallback')
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
const session = await provider.spawn({ cols: 80, rows: 24 })
delete (fallback as { hasPty?: (id: string) => boolean }).hasPty

await provider.shutdown(session.id, { immediate: true })

expect(provider.hasPty(session.id)).toBe(true)
})

it('routes authoritative recovery snapshots to the owning daemon', async () => {
const current = createDaemonAdapter('daemon', ['daemon-session'])
const fallback = createProvider('fallback')
Expand Down
56 changes: 27 additions & 29 deletions src/main/daemon/degraded-daemon-pty-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import { ShutdownVerificationOwnerCache } from './shutdown-verification-owner-cache'
import { reconcileDegradedDaemonSessions } from './degraded-daemon-session-reconciliation'
import { subscribeToProviderReplay } from './provider-replay-subscription'

type ManagedPtyProvider = IPtyProvider & {
disconnectOnly?: () => Promise<void>
Expand All @@ -25,6 +28,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
private legacy: DaemonPtyAdapter[]
private fallback: ManagedPtyProvider
private sessionProviders = new Map<string, ManagedPtyProvider>()
private shutdownVerificationProviders = new ShutdownVerificationOwnerCache<ManagedPtyProvider>()
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: {
id: string
Expand Down Expand Up @@ -65,6 +69,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
const sessions = await adapter.listProcesses()
for (const session of sessions) {
this.sessionProviders.set(session.id, adapter)
this.shutdownVerificationProviders.delete(session.id)
}
} catch (error) {
console.warn('[daemon] Failed to discover degraded daemon sessions', error)
Expand All @@ -77,14 +82,20 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
const target = mapped ?? this.fallback
const result = await target.spawn(opts)
this.sessionProviders.set(result.id, target)
this.shutdownVerificationProviders.delete(result.id)
return result
}

async attach(id: string): Promise<void> {
await this.providerFor(id).attach(id)
this.shutdownVerificationProviders.delete(id)
}

hasPty(id: string): boolean {
const shutdownOwner = this.shutdownVerificationProviders.take(id)
if (shutdownOwner) {
return shutdownOwner.hasPty?.(id) ?? true
}
const mapped = this.sessionProviders.get(id)
if (mapped) {
return mapped.hasPty?.(id) ?? true
Expand Down Expand Up @@ -113,9 +124,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
}

async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.providerFor(id).shutdown(id, opts)
const owner = this.providerFor(id)
await owner.shutdown(id, opts)
if (!opts.keepHistory) {
this.sessionProviders.delete(id)
this.shutdownVerificationProviders.remember(id, owner)
}
}

Expand Down Expand Up @@ -170,6 +183,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {

async revive(state: string): Promise<void> {
await this.fallback.revive(state)
this.shutdownVerificationProviders.clear()
}

async listProcesses(): Promise<PtyProcessInfo[]> {
Expand Down Expand Up @@ -211,23 +225,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
}

onReplay(callback: (payload: { id: string; data: string }) => void): () => void {
const unsubscribes = this.allProviders().map((provider) => provider.onReplay(callback))
let active = true
const trackedUnsubscribe = (): void => {
if (!active) {
return
}
active = false
const idx = this.unsubscribers.indexOf(trackedUnsubscribe)
if (idx !== -1) {
this.unsubscribers.splice(idx, 1)
}
for (const unsubscribe of unsubscribes) {
unsubscribe()
}
}
this.unsubscribers.push(trackedUnsubscribe)
return trackedUnsubscribe
return subscribeToProviderReplay(this.allProviders(), callback, this.unsubscribers)
}

onExit(callback: (payload: { id: string; code: number }) => void): () => void {
Expand All @@ -252,23 +250,22 @@ 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)
return reconcileDegradedDaemonSessions(
this.allDaemonAdapters(),
validWorktreeIds,
(id, adapter) => {
this.sessionProviders.set(id, adapter)
}
for (const id of result.killed) {
killed.push(id)
this.shutdownVerificationProviders.delete(id)
},
(id) => {
this.sessionProviders.delete(id)
this.shutdownVerificationProviders.delete(id)
}
}
return { alive, killed }
)
}

dispose(): void {
this.shutdownVerificationProviders.clear()
this.disposeProviderOnly()
for (const adapter of this.allDaemonAdapters()) {
adapter.dispose()
Expand All @@ -292,6 +289,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
fanoutCurrentDaemonSyntheticExits(code: number): void {
for (const id of this.getCurrentDaemonSessionIds()) {
this.sessionProviders.delete(id)
this.shutdownVerificationProviders.delete(id)
// Why: sessions discovered from listProcesses may not exist in the
// adapter's active-session set, but restart still kills that daemon.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
Expand Down
23 changes: 23 additions & 0 deletions src/main/daemon/degraded-daemon-session-reconciliation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'

export async function reconcileDegradedDaemonSessions(
adapters: readonly DaemonPtyAdapter[],
validWorktreeIds: Set<string>,
onAlive: (id: string, adapter: DaemonPtyAdapter) => void,
onKilled: (id: string) => void
): Promise<{ alive: string[]; killed: string[] }> {
const alive: string[] = []
const killed: string[] = []
for (const adapter of adapters) {
const result = await adapter.reconcileOnStartup(validWorktreeIds)
for (const id of result.alive) {
alive.push(id)
onAlive(id, adapter)
}
for (const id of result.killed) {
killed.push(id)
onKilled(id)
}
}
return { alive, killed }
}
Loading
Loading