Files
orca/src/main/ipc/ssh-shutdown-drain.ts
T
OrcaWinandm4air 5f308bfa9c revert: take the 26 Phase 3 (#16741 port) PRs back out of main (#24559)
* Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)"

This reverts commit 783101b304.

* Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)"

This reverts commit 38c2d1dcb9.

* Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)"

This reverts commit 8b76683b40.

* Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)"

This reverts commit d3f8c5063b.

* Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)"

This reverts commit 43d9b43d3f.

* Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)"

This reverts commit b093d3ab20.

* Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)"

This reverts commit 1a9ac0e955.

* Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)"

This reverts commit 99db2bfae4.

* Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)"

This reverts commit 34a582bd39.

* Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)"

This reverts commit d53063d2b1.

* Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)"

This reverts commit ff212dbbef.

* Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)"

This reverts commit 92cb71765e.

* Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)"

This reverts commit 6b36e4f85b.

* Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)"

This reverts commit d23ecef301.

* Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)"

This reverts commit dd87ae578d.

* Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)"

This reverts commit ece9e4d2e3.

* Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)"

This reverts commit 3fbdaba262.

* Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)"

This reverts commit 4e8edc8872.

* Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)"

This reverts commit 60c93263cc.

* Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)"

This reverts commit 99e0303572.

* Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)"

This reverts commit 817af768b0.

* Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)"

This reverts commit c9918931c8.

* Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)"

This reverts commit dc08ffeba9.

* Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)"

This reverts commit a789233bbb.

* Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)"

This reverts commit 0b812bd698.

* Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)"

This reverts commit 3aa2d3af7c.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-02 00:52:32 -07:00

159 lines
6.0 KiB
TypeScript

import { activeSessions } from './ssh-active-relay-sessions'
import {
connectInFlight,
invalidateConnectAttempt,
resetRelayInFlight,
testConnectionProbes
} from './ssh-connect-attempt-registry'
import { connectionManager } from './ssh-ipc-context'
import { teardownActiveSshSession } from './ssh-session-teardown'
// Why one budget for the whole sequence rather than one per phase: an invalidated connect only
// observes its cancellation at the next checkpoint, and one blocked in the transport handshake can
// sit there for the whole SSH timeout. A per-phase timeout lets any single phase consume the global
// quit deadline; a shared absolute deadline cannot.
export const SSH_SHUTDOWN_BUDGET_MS = 6_000
export type SshShutdownPhase = 'drain' | 'in-flight-join' | 'final-drain'
export type SshShutdownUnfinished = { targetId: string; phase: SshShutdownPhase }
export type SshShutdownResult = {
unfinished: readonly SshShutdownUnfinished[]
errors: readonly unknown[]
}
type SshShutdownTask = { targetId: string; promise: Promise<unknown> }
let sshShutdownDrain: Promise<SshShutdownResult> | null = null
export function resetSshShutdownDrain(): void {
sshShutdownDrain = null
}
async function settleTasksWithinMs(
tasks: readonly SshShutdownTask[],
timeoutMs: number
): Promise<{ timedOut: SshShutdownTask[]; errors: unknown[] }> {
const pending = new Set(tasks)
const errors: unknown[] = []
if (tasks.length === 0) {
return { timedOut: [], errors }
}
const tracked = tasks.map(async (task) => {
try {
await task.promise
} catch (error) {
errors.push(error)
}
pending.delete(task)
})
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
Promise.all(tracked),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
timer.unref?.()
})
])
} finally {
clearTimeout(timer)
}
return { timedOut: [...pending], errors }
}
function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] {
return [
...targetIds
.filter((targetId) => activeSessions.has(targetId))
.map((targetId) => ({
targetId,
promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist())
})),
{ targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() }
]
}
async function drainSshShutdown(
targetIds: readonly string[],
inFlight: readonly SshShutdownTask[],
detachErrors: readonly unknown[] = []
): Promise<SshShutdownResult> {
const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS
const unfinished: SshShutdownUnfinished[] = []
const errors: unknown[] = [...detachErrors]
const runPhase = async (
phase: SshShutdownPhase,
tasks: readonly SshShutdownTask[]
): Promise<boolean> => {
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
unfinished.push(...tasks.map((task) => ({ targetId: task.targetId, phase })))
return false
}
const settled = await settleTasksWithinMs(tasks, remainingMs)
errors.push(...settled.errors)
unfinished.push(...settled.timedOut.map((task) => ({ targetId: task.targetId, phase })))
return settled.timedOut.length === 0
}
await runPhase('drain', sshShutdownTasks(targetIds))
// Why a second drain after the join: a connect paused in old-session teardown still publishes its
// replacement session and opens a transport before it reaches the cancellation checkpoint, so the
// first drain can miss both.
if (await runPhase('in-flight-join', inFlight)) {
await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()]))
}
if (errors.length > 0 || unfinished.length > 0) {
// Why one aggregate line: per-target logging on the quit path competes with the final flush for
// the little time that remains.
console.warn(
`[ssh] Shutdown drain finished with ${errors.length} error(s); unfinished: ${
unfinished.map((entry) => `${entry.targetId}/${entry.phase}`).join(', ') || 'none'
}`
)
}
return { unfinished, errors }
}
// Why one entry point that returns rather than awaits: every in-memory transition the final store
// flush must snapshot happens synchronously here, before this returns, so the caller can start that
// flush with no await in between. Idempotent — a later call joins the same drain and repeats no
// state transition.
//
// Why no fence latch here: the committed quit path sets it before calling this, so it is already on
// for the snapshot below. Called without that gate (tests), this degrades to a plain drain.
export function beginSshShutdown(): Promise<SshShutdownResult> {
if (sshShutdownDrain) {
return sshShutdownDrain
}
const inFlight: SshShutdownTask[] = [
...[...connectInFlight.entries()].map(([targetId, attempt]) => ({
targetId,
promise: attempt.promise
})),
...[...resetRelayInFlight.entries()].map(([targetId, promise]) => ({ targetId, promise })),
...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise }))
]
for (const targetId of Array.from(connectInFlight.keys())) {
invalidateConnectAttempt(targetId)
}
const targetIds = [...activeSessions.keys()]
// Why before any await: this is the whole point of the split. Each session marks its recovery lease
// detached in memory now, and the final flush persists it — the remote PTYs keep running.
const detachErrors: unknown[] = []
for (const session of activeSessions.values()) {
// Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw
// (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape
// it and skip every later session, the drain assignment, and the store flush that persists all
// of this. Collect and keep going; the drain reports them.
try {
session.beginShutdownDetach()
} catch (error) {
detachErrors.push(error)
}
}
sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors)
return sshShutdownDrain
}