/** * T5 · Heartbeat / liveness (§4.1) — 15s PING/PONG with an injectable timer. * * PING carries a fresh 8-byte crypto-random token every interval; the matching PONG must echo * the EXACT outstanding token to reset the miss counter. An unmatched/stale/replayed PONG is * ignored (INV13-adjacent: a replayed PONG can't keep a dead/hijacked tunnel "alive"). * `HEARTBEAT_MISS_LIMIT` consecutive missed PONGs (~45s) ⇒ `onDead` fires exactly once. */ import { randomBytes } from 'node:crypto' export const HEARTBEAT_INTERVAL_MS = 15_000 export const HEARTBEAT_MISS_LIMIT = 3 const TOKEN_BYTES = 8 export interface ScheduleHandle { cancel(): void } export interface HeartbeatDeps { sendPing(token: Uint8Array): void onDead(): void now?: () => number schedule?: (fn: () => void, ms: number) => ScheduleHandle intervalMs?: number } export interface Heartbeat { start(): void stop(): void onPong(token: Uint8Array): void } function defaultSchedule(fn: () => void, ms: number): ScheduleHandle { const id = setInterval(fn, ms) return { cancel: () => clearInterval(id) } } function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false } return true } export function createHeartbeat(deps: HeartbeatDeps): Heartbeat { const schedule = deps.schedule ?? defaultSchedule const intervalMs = deps.intervalMs ?? HEARTBEAT_INTERVAL_MS let handle: ScheduleHandle | null = null let outstanding: Uint8Array | null = null // token awaiting a PONG, or null once matched let misses = 0 let dead = false function tick(): void { if (dead) return // If the previous PING was never answered, count a miss. if (outstanding !== null) { misses += 1 if (misses >= HEARTBEAT_MISS_LIMIT) { dead = true stop() deps.onDead() return } } const token = new Uint8Array(randomBytes(TOKEN_BYTES)) outstanding = token deps.sendPing(token) } function stop(): void { if (handle !== null) { handle.cancel() handle = null } } return { start() { if (handle !== null || dead) return // Send the first PING immediately, then on every interval. tick() handle = schedule(tick, intervalMs) }, stop, onPong(token) { if (dead || outstanding === null) return if (bytesEqual(token, outstanding)) { outstanding = null misses = 0 } // A stale/unknown token is ignored — it does NOT reset the miss counter. }, } }