hermes-agent/plugins/platforms/photon/sidecar/stream-staleness.mjs
Teknium dcace573da fix(photon): rework zombie-stream watchdog for spectrum-ts 8 with strict probe semantics
Maintainer rework of #45580 (issue #54036) on top of the contributor's
cherry-pick, which targeted spectrum-ts 3.1.0 while main pins 8.0.0:

Sidecar (primary detection, new):
- stream-staleness.mjs: pure decision rules, executable under node.
  * classifyProbeRejection: only a not-found-shaped rejection of the
    synthetic-id read counts as a completed round-trip (ALIVE); any other
    rejection is INCONCLUSIVE — never alive. The original /probe treated
    ANY rejection as alive, which was too loose.
  * shouldProbe: probe only after 10+ min of stream silence (configurable
    via PHOTON_STREAM_SILENCE_PROBE_MS; <=0 disables) with a cooldown.
  * isZombieSuspect: zombie only on silence past threshold AND a
    probe-proven live channel. Silence alone NEVER degrades (shared lines
    can be quiet for hours); inconclusive probes NEVER degrade (network
    may be down — the iterator will throw and the re-subscribe loop
    recovers on its own).
- index.mjs: track last inbound-iterator yield (noteInboundYield), run a
  30s watchdog tick, and on a confirmed zombie feed markStreamDegraded ->
  the existing exit-75 restart path. /healthz gains a stream.staleness
  block (silentForMs, threshold, lastProbeOutcome, zombieSuspected).
  /probe reworked to strict semantics: 200 only on a proven round-trip,
  503 with outcome hung|inconclusive otherwise.

Adapter (second layer, reworked):
- _probe_once returns tri-state alive|hung|inconclusive; only a hung
  sidecar HTTP call counts toward the respawn counter — inconclusive
  resets nothing and triggers nothing.
- default probe_interval_seconds 60 -> 600 (conservative; avoid restart
  storms on quiet lines).
- _monitor_sidecar_health surfaces zombieSuspected from /healthz as a
  warning; the fatal UPSTREAM_STREAM_DEGRADED path is unchanged and fires
  when the sidecar escalates.

Tests: test_zombie_stream_watchdog.py executes the real node decision
module and drives the adapter against mocked /healthz responses;
test_presence_watchdog.py updated for the tri-state probe.

Also adds contributor mappings for nickkarhan (#53283) and vaibhavjnf
(#45580).
2026-07-28 22:22:42 -07:00

80 lines
3.8 KiB
JavaScript

// Pure decision helpers for the zombie-stream (half-open gRPC) watchdog.
//
// spectrum-ts only reconnects when its inbound async iterator throws or ends.
// A half-open ("zombie") socket makes the iterator hang forever — no error,
// no end — so inbound silently dies while /healthz still looks fine. The
// watchdog in index.mjs tracks the last time the inbound iterator yielded and,
// once the stream has been silent past a conservative threshold, drives a
// cheap authenticated unary read over the same channel. STRICT semantics:
//
// - probe resolves, or rejects with a not-found-shaped error for our
// synthetic id -> ALIVE (the wire round-tripped)
// - probe rejects any other way (UNAVAILABLE, DEADLINE_EXCEEDED, network
// down, ...) -> INCONCLUSIVE — never treated as alive, and
// never treated as zombie-proof either
//
// A zombie is only declared when the stream is silent past the threshold AND
// a probe proves connectivity (the wire works but the stream is deaf). Silence
// alone NEVER degrades the stream: shared lines can be legitimately quiet for
// hours. Inconclusive probes NEVER degrade it either: the network may simply
// be down, and in that case the iterator will eventually throw and the
// existing re-subscribe loop recovers on its own.
//
// These helpers are pure (no SDK, no timers) so tests can execute them under
// node — see tests/plugins/platforms/photon/test_zombie_stream_watchdog.py.
// gRPC NOT_FOUND is code 5; SDKs also surface it as "not found" / "NotFound"
// message text. Anything not clearly not-found is inconclusive.
const NOT_FOUND_RE = /not[\s_-]?found/i;
/**
* Classify the rejection of the synthetic-id probe read.
*
* @param {unknown} err error thrown by `space.getMessage(<synthetic id>)`
* @returns {{alive: boolean, inconclusive: boolean, reason: string}}
*/
export function classifyProbeRejection(err) {
const code = err && typeof err === "object" ? err.code : undefined;
const message =
err && typeof err === "object" && err.message
? String(err.message)
: String(err);
if (code === 5 || code === "notFound" || NOT_FOUND_RE.test(message)) {
// Expected: the synthetic id doesn't exist. The unary call completed a
// round-trip, so the channel is provably alive.
return { alive: true, inconclusive: false, reason: "not-found round-trip" };
}
// Anything else (UNAVAILABLE, DEADLINE_EXCEEDED, TLS, auth, ...) does NOT
// prove liveness — and doesn't prove a zombie either.
return { alive: false, inconclusive: true, reason: message };
}
/**
* Should the watchdog probe at all this tick?
*
* @param {number} silentForMs ms since the inbound iterator last yielded
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
* @param {number} sinceLastProbeMs ms since the previous probe attempt
* @param {number} probeCooldownMs min spacing between probe attempts
* @returns {boolean}
*/
export function shouldProbe(silentForMs, thresholdMs, sinceLastProbeMs, probeCooldownMs) {
if (!(thresholdMs > 0)) return false;
if (silentForMs < thresholdMs) return false;
return sinceLastProbeMs >= probeCooldownMs;
}
/**
* Final classification: zombie only on silence past threshold + probe-proven
* connectivity. Never on silence alone, never on an inconclusive probe.
*
* @param {number} silentForMs ms since the inbound iterator last yielded
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
* @param {{alive: boolean}} probeOutcome
* @returns {boolean}
*/
export function isZombieSuspect(silentForMs, thresholdMs, probeOutcome) {
if (!(thresholdMs > 0)) return false;
if (silentForMs < thresholdMs) return false;
return probeOutcome != null && probeOutcome.alive === true;
}