fix(web): scope every memory-banner dismissal to boot_id (NS-656)

Review edge case: OOM dismissal was boot-keyed but live critical/elevated
dismissals were keyed only by severity. Dismiss critical, gateway reboots,
next poll still critical with no observed "ok" in between — the new
incident stayed hidden, and masked the OOM notice too, since critical
takes precedence.

Every dismissal key now embeds boot_id, so a gateway restart invalidates
prior dismissals of any kind. Within a boot, semantics are unchanged:
severity-scoped masking, escalation re-opens, confirmed "ok" recovery
clears live dismissals, "unknown" clears nothing. Missing boot_id
(pre-NS-656 image) degrades to a shared per-severity bucket as before.
This commit is contained in:
Shannon Sands
2026-08-13 16:32:15 +10:00
committed by Teknium
parent ba5dc00bb2
commit f5a26b1575
2 changed files with 62 additions and 20 deletions
@@ -161,6 +161,47 @@ describe("MemoryPressureBanner", () => {
expect(banner()?.textContent).toContain("restarted unexpectedly");
});
it("a gateway reboot (boot_id change) re-opens a dismissed live-pressure banner", async () => {
// Edge case: dismiss critical, gateway restarts, next poll is STILL
// critical with no observed "ok" in between. The new boot is a new
// incident — without boot-scoped keys it would stay hidden (and mask
// the OOM notice too, since critical takes precedence).
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Same boot, still critical: stays dismissed.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
expect(banner()).toBeNull();
// Rebooted, still critical: new incident, banner returns.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T02:00:00+00:00",
})}
/>,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("recovery to ok resets a dismissed live-pressure banner for the next episode", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
+21 -20
View File
@@ -17,13 +17,14 @@ import { useI18n } from "@/i18n";
* OOM-killed hourly while the dashboard looked healthy.
*
* Dismissal semantics (session-scoped, sessionStorage):
* - OOM-restart dismissal is keyed to the reporting boot (`boot_id`), so a
* LATER restart — the hourly-loop case this exists for — re-opens the
* banner in the same tab.
* - Live-pressure dismissal masks only the dismissed severity; escalation
* (elevated → critical) re-opens immediately, and a confirmed recovery
* (pressure back to "ok", not "unknown") clears live dismissals so the
* NEXT episode surfaces again.
* - EVERY dismissal key embeds the reporting boot (`boot_id`), so a gateway
* restart invalidates all of them. Without this, dismissing `critical`,
* rebooting, and coming back still-critical would hide the NEW incident —
* and mask the OOM notice too, since critical takes precedence.
* - Within one boot, live-pressure dismissal masks only the dismissed
* severity; escalation (elevated → critical) re-opens immediately, and a
* confirmed recovery (pressure back to "ok", not "unknown") clears live
* dismissals so the NEXT episode in the same boot surfaces again.
*/
const STORAGE_KEY = "memoryBannerDismissed";
@@ -68,15 +69,15 @@ export function MemoryPressureBanner({
// pressure is demonstrably back to "ok", any dismissed live-pressure
// entries describe a PAST episode — drop them so the next one isn't
// silently hidden. "unknown" (stale/absent heartbeat) is absence of
// evidence, not recovery, and clears nothing.
// evidence, not recovery, and clears nothing. Cross-boot invalidation
// doesn't need handling here: boot_id is part of every dismissal key.
const [prevPressure, setPrevPressure] = useState(pressure);
if (pressure !== prevPressure) {
setPrevPressure(pressure);
if (
pressure === "ok" &&
dismissed.some((entry) => LIVE_TRIGGERS.includes(entry))
) {
const next = dismissed.filter((entry) => !LIVE_TRIGGERS.includes(entry));
const isLiveEntry = (entry: string) =>
LIVE_TRIGGERS.some((sev) => entry === sev || entry.startsWith(`${sev}:`));
if (pressure === "ok" && dismissed.some(isLiveEntry)) {
const next = dismissed.filter((entry) => !isLiveEntry(entry));
writeDismissed(next);
setDismissed(next);
}
@@ -93,13 +94,13 @@ export function MemoryPressureBanner({
? "elevated"
: null;
// OOM-restart dismissal is per incident (boot), live-pressure dismissal is
// per severity. A missing boot_id (degraded payload) degrades to a shared
// bucket — old behavior, never a crash.
const dismissKey =
trigger === "oom_restart"
? `oom_restart:${memory?.boot_id ?? "unknown"}`
: trigger;
// Every dismissal is scoped to the reporting boot: `boot_id` changes on
// each gateway life, so restarts invalidate prior dismissals of ANY kind.
// A missing boot_id (degraded payload / pre-NS-656 image) degrades to a
// shared per-severity bucket — old behavior, never a crash.
const dismissKey = trigger
? `${trigger}:${memory?.boot_id ?? "unknown"}`
: null;
if (!trigger || !dismissKey || dismissed.includes(dismissKey)) return null;