Skip to content

K2GO-386 feat(app-backstop): firehose signal as a second reap trigger (L3a) - #551

Merged
luisguzman-adfa merged 5 commits into
mainfrom
feat/K2GO-386-firehose-signal
Sep 5, 2026
Merged

K2GO-386 feat(app-backstop): firehose signal as a second reap trigger (L3a)#551
luisguzman-adfa merged 5 commits into
mainfrom
feat/K2GO-386-firehose-signal

Conversation

@luisguzman-adfa

Copy link
Copy Markdown
Collaborator

What

A second reap trigger for the app-side backstop (ADR-386 section 6). dash-node
exposes a live firehose signal on /system, and the app polls it to reap a
recurring firehose the box cannot stop -- even before the disk goes low.

Stacked on feat/K2GO-386-app-backstop (L3b). Review this after that one; the
diff here is L3a only.

Why

L3b (the first trigger) reaps when free space already went CRITICAL. But Layer 2
truncates a runaway .log every tick, so the disk may never go low while an
off-proot orphan firehoses forever. The in-box guard can detect and reclaim, but
it cannot stop that orphan (device-proven). The app can. It needs to know a
firehose keeps coming back, so it can reap it early -- before the disk fills.

How

Server (dash-node):

  • New read-only GET /system/disk-guard/firehose returns
    { recurring, maxStreak, paths, lastTruncatedAtMs, now }. It reads the guard's
    LIVE in-memory streak, never a parsed log line, so a restart-resolved firehose
    reports clean. recurring is maxStreak >= 2. lastTruncatedAtMs and now
    (both server wall-clock) let the app judge freshness with no clock skew.
  • Version 1.3.0 -> 1.3.1 (new REST endpoint).

App:

  • FirehoseSignal (pure): the parsed signal plus isFresh, judged in the
    server's clock. Unit-tested on a plain JVM.
  • FirehoseSignalSource: reads the endpoint; returns null when the box is off.
  • DiskGuard.checkFirehoseSignal: the signal is only an ALERT. Before it reaps,
    the app RE-PROBES live growth -- it sums the .log bytes under /var/log,
    waits, sums again, and acts only on a firehose-sized delta. So it never acts on
    a stale signal (confirm before acting). On a confirmed firehose it reaps,
    reclaims, and relaunches (restart-to-keep-alive). It does NOT touch the low-disk
    escalation counter -- that path stays the single escalation authority.
  • Polled every 6th guard tick (~150 s), since the signal only advances on
    dash-node's 10-min tick; the low-disk check still runs every tick.

Verification

  • Unit: FirehoseSignalTest (green); dash-node log-rotate.test.ts +
    tsc --noEmit (green).
  • Device-verified full chain on a OnePlus 7T (HD1901), dash-node 1.3.1: a
    continuous writer made the guard truncate a >1 GiB log on two consecutive ticks
    -> the endpoint went recurring:true, maxStreak:2 -> the app's poll read it,
    confirmed live growth, and reaped ("contained recurring firehose (streak 2)").
    The box relaunched; the report carried action:contained_firehose, trip:2
    (the real server streak, not the debug value). After the reap the endpoint reset
    to recurring:false -- the in-memory streak clears on restart, as designed.

Coordination

… server half)

The in-box guard (1.3.0) truncates a runaway .log every tick, so the disk may
never go low. But a recurring firehose is an off-proot orphan the box cannot
stop; only an app-side reap can. The app needs a SECOND reap trigger beyond its
own low-disk read.

Add GET /system/disk-guard/firehose returning
{ recurring, maxStreak, paths, lastTruncatedAtMs, now }. It reads the guard's
LIVE in-memory streak map, never a parsed log line, so a restart-resolved
firehose reports clean (ADR-386 §6, confirm before acting). recurring is
maxStreak >= 2. lastTruncatedAtMs lets the app judge freshness; the app
re-probes live growth before it reaps.

log-rotate.ts: track lastTruncatedAtMs; add pure summarizeStreaks (unit-tested)
and getFirehoseState wrapping it. Version 1.3.0 -> 1.3.1 (new REST endpoint).
… (L3a app half)

The low-disk trigger catches a disk that already went low. But the in-box guard
truncates a runaway .log every tick, so an off-proot orphan can firehose forever
without the disk ever going low. The app needs to reap that orphan early.

Add a second trigger to the guard poller. checkFirehoseSignal reads dash-node's
live signal (GET /system/disk-guard/firehose). FirehoseSignal.isFresh gates on a
recurring firehose whose last truncation is recent, judged in the server's own
clock (no app-vs-server skew). The signal is only an ALERT: before reaping, the
app re-probes live growth -- it sums the .log bytes under /var/log, waits, sums
again, and acts only on a firehose-sized delta. So it never acts on a stale
signal (confirm before acting, ADR-386 section 6).

On a confirmed firehose it reaps + reclaims + relaunches (restart-to-keep-alive);
the app-side reap reaches the off-proot orphan, so a fresh box does not refill.
It does NOT touch the low-disk escalation counter -- that path stays the single
escalation authority. Both paths skip the reap while a deep op holds the box.

FirehoseSignal (pure, unit-tested) + FirehoseSignalSource (HttpURLConnection,
returns null when the box is off). Debug receiver gains --ez firehose true to
exercise the path on device without a real 20-min streak. Domain tests green
(FirehoseSignal 5, Escalation 5, Policy 4). See ADR-386 section 6 + 10.
…; unify log walk (L3a review)

Second code-review pass on L3a. Four fixes.

1. Growth threshold. GROWTH_MIN_BYTES 32 MiB -> 16 MiB. The observed firehose
   runs ~600 MB/min to 1.3 GB/min; at the low end that is ~30 MB in the 3s probe,
   which is under 32 MiB, so a slow-but-real firehose failed the confirm and was
   never reaped. 16 MiB (~327 MB/min) catches the low end with margin and stays
   far above any normal log.

2. Poll cadence. The firehose signal is an HTTP GET, and dash-node only advances
   it on its 10-min guard tick, so read it every 6th tick (~150s) instead of
   every 25s. The low-disk StatFs check still runs every tick.

3. Log walk. One recursive collectLogs(dir, out); totalLogBytes (sum) and
   biggestLog (max) reduce over it. Removes the duplicate walker (sumLogBytes vs
   biggestLogUnder).

4. Comment honesty. confirmFirehoseGrowing no longer claims to be robust to a
   truncation mid-probe: that rare race reads as no-growth this tick, not a false
   reap, and the next tick catches it.
…tics

A disk-guard event is an operational diagnostic, not behavioural analytics.
report() used DeliveryManager.enqueueAnalytics, which is gated on AnalyticsConsent
(opt-in, default OFF) -- so by default the report was silently dropped, and the
channel was wrong in kind.

Send it to GlitchTip via Sentry instead (Sentry.withScope + captureMessage,
WARNING), with the fields as tags/extras. Gated by CrashReportConsent (default
ON, its own policy, no PII -- IIABApplication, ADFA-4533). A no-op when crash
reporting is off or Sentry has no DSN. This also drops the flat JSON envelope in
favour of structured tags.

ADR-386: section 7 rewritten to the two-channel model (automatic -> GlitchTip;
active user-sent -> feedback email); section 12 added to scope the active report
as the closing piece of the effort (its own ticket). Verification table notes the
full real firehose chain device-verified.
@luisguzman-adfa
luisguzman-adfa merged commit 14ee69c into main Sep 5, 2026
4 checks passed
@luisguzman-adfa
luisguzman-adfa deleted the feat/K2GO-386-firehose-signal branch September 5, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant