/** * T11 · Graceful drain + GOAWAY (INV7/INV12). GOAWAY on streamId 0 to every (or one) tunnel, * then teardown. The grace window is REASON-DIFFERENTIATED (Finding-2): * effectiveGraceMs = (reason === revoked) ? 0 : inFlightGraceMs * `revoked` is a SECURITY action against a compromised host/device — it forces immediate teardown * (RST/close now, deregister now) regardless of any caller-supplied grace, so INV12's "its live * tunnel drops within seconds" holds even when operator-drain grace is tuned to tens of seconds. * `operatorDrain`/`shutdown` keep a grace window purely for clean migration. */ import type { AgentTunnel, RouteRegistrar } from './agent-listener.js' /** Drain reason ⇄ §4.1 GOAWAY wire code (frozen in relay-contracts as GOAWAY_REASON_TO_CODE). */ export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const export type DrainReason = (typeof DRAIN_REASON)[keyof typeof DRAIN_REASON] export interface DrainDeps { tunnels(): ReadonlyMap registrar: RouteRegistrar reason: DrainReason inFlightGraceMs: number // Injected timer for the grace window (default global setTimeout); testability seam. setTimeoutFn?: (fn: () => void, ms: number) => unknown } export interface DrainHostDeps extends DrainDeps { // single-host target resolved by drainHost's first arg } function effectiveGrace(reason: DrainReason, inFlightGraceMs: number): number { return reason === DRAIN_REASON.revoked ? 0 : Math.max(0, inFlightGraceMs) } async function tearDownTunnel(t: AgentTunnel, registrar: RouteRegistrar): Promise { t.closeTunnel() // socket close (agent side sees the streams die) await registrar.deregister(t.hostId) // ingress stops routing to this host on this node } /** GOAWAY then teardown one tunnel, honoring the reason-differentiated grace. */ function drainOne( t: AgentTunnel, reason: DrainReason, inFlightGraceMs: number, registrar: RouteRegistrar, setTimeoutFn: (fn: () => void, ms: number) => unknown, ): Promise { t.session.drain(0, reason) // §4.1 GOAWAY on streamId 0; refuses new streams thereafter const grace = effectiveGrace(reason, inFlightGraceMs) if (grace === 0) { return tearDownTunnel(t, registrar) // immediate (revoked, or zero-grace operator drain) } setTimeoutFn(() => { void tearDownTunnel(t, registrar) }, grace) return Promise.resolve() // grace scheduled; drain initiated } /** Drain EVERY tunnel on the node (operator drain / shutdown / node-wide revocation). */ export async function drainNode(deps: DrainDeps): Promise { const setTimeoutFn = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms)) await Promise.all( [...deps.tunnels().values()].map((t) => drainOne(t, deps.reason, deps.inFlightGraceMs, deps.registrar, setTimeoutFn), ), ) } /** Drain exactly ONE host, leaving siblings running (whole-host revocation / operator drain, INV12). */ export async function drainHost(hostId: string, deps: DrainHostDeps): Promise { const setTimeoutFn = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms)) const t = deps.tunnels().get(hostId) if (t === undefined) return // no-op: already gone (deny-by-default, never a broader kill) await drainOne(t, deps.reason, deps.inFlightGraceMs, deps.registrar, setTimeoutFn) }