fix(alerts): retain ownership of incident history requests

Invalidate pending reads on reset and disposal, and gate success, failure and loading writes per resource. Convert the four reproductions to ordinary tests and retain reset/reopen and stale-failure controls. Qualify the real hook and panel with 14 Chromium lifecycle cases; register that exact browser surface proof without broadening path policies. This does not qualify installed delivery or PR1973's absent error accessor.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-08 02:55:51 +01:00
parent bbec8d0e9f
commit e5ef265c77
8 changed files with 329 additions and 28 deletions
@@ -2743,3 +2743,24 @@ rename retry and Unix destination hardening. This addresses one write source
in #1966, not total installed write amplification: directory metadata handling,
intent snapshots, changing alert histories and database writes remain separate.
It changes neither alert latency nor the selected release candidate.
### Resource incident reads retain lifecycle ownership
The resource incident hook gives each started read a unique per-resource owner.
Only that owner may publish history, report a failure or clear loading. Reset
invalidates all pending owners before clearing state; disposal invalidates them
and prevents new loads. Overlapping reads for different resources remain
independent. Closing a row still permits its in-flight result to populate the
existing cache; reopening cached history and explicit refresh are unchanged.
Requests are not transport-cancelled. No API, retention or notification-delivery
policy changes.
The hook's ten ordinary regression/control cases cover success, catch and
finally writes, reset/reopen and disposal. The existing panel tests cover its
presentation. `scripts/check-incident-request-ownership.mjs` exercises the real
hook and panel in Chromium at desktop and phone widths with scripted responses
and fixture reset, overlap and unmount controls. It is component lifecycle
acceptance, not an installed full-page or notification-delivery receipt.
PR1973's proposed resourceIncidentError accessor is absent here; if introduced,
its writes must obey the same owner check and gain a stale-error regression.
@@ -7222,3 +7222,24 @@ widths despite the longer labels. The presentation and Overview delivery-status
tests cover the evidence boundary; `scripts/check-alert-dispatch-copy.mjs`
qualifies the real Overview with scripted API data in Chromium, not installed
notification delivery.
### Resource incident reads retain lifecycle ownership
The resource incident hook gives each started read a unique per-resource owner.
Only that owner may publish history, report a failure or clear loading. Reset
invalidates all pending owners before clearing state; disposal invalidates them
and prevents new loads. Overlapping reads for different resources remain
independent. Closing a row still permits its in-flight result to populate the
existing cache; reopening cached history and explicit refresh are unchanged.
Requests are not transport-cancelled. No API, retention or notification-delivery
policy changes.
The hook's ten ordinary regression/control cases cover success, catch and
finally writes, reset/reopen and disposal. The existing panel tests cover its
presentation. `scripts/check-incident-request-ownership.mjs` exercises the real
hook and panel in Chromium at desktop and phone widths with scripted responses
and fixture reset, overlap and unmount controls. It is component lifecycle
acceptance, not an installed full-page or notification-delivery receipt.
PR1973's proposed resourceIncidentError accessor is absent here; if introduced,
its writes must obey the same owner check and gain a stale-error regression.
@@ -2590,7 +2590,8 @@
"frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts",
"frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx",
"frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts",
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts"
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts",
"scripts/check-incident-request-ownership.mjs"
]
},
{
+21 -13
View File
@@ -1,35 +1,43 @@
{
"version": 1,
"base_sha": "c857802df6b00e61e2229eea411d6675deff2d8a",
"verified_at": "2026-09-07T22:03:54.189527Z",
"base_sha": "bbec8d0e9f282272080d8781d763248b3e8ea467",
"verified_at": "2026-09-08T01:55:51.776040Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/utils/resourceStateAdapters.ts"
"frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts"
],
"content_sha256": {
"frontend-modern/src/utils/resourceStateAdapters.ts": "ce1f8f2aef4b04b64dd2533c09458349811ba7cec9ec2e4bd8634b8e9c3c848f"
"frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts": "547f128db61200fdf736327a248e9c97b41dd77eeb0a660dd20c4b78031a75b9"
},
"routes": [
"/proxmox"
"/qualification (isolated real hook and AlertResourceIncidentsPanel, scripted API)"
],
"viewports": [
{
"width": 1280,
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
"height": 900
}
],
"states": [
"Canonical 100% to 35%; stale/offline/unavailable agent evidence retains trusted 100%.",
"Explicit Proxmox withdrawal with omitted canonical memory: table N/A, Memory section absent.",
"Measured-zero recovery: table 0%, Memory Total and Free both 100 MB; second cluster stays 80%."
"loading",
"latest result survives older success/failure",
"reset stays empty",
"disposed state unchanged by success/failure",
"current failure reported"
],
"interactions": [
"Open guest drawer; deliver synthetic canonical socket snapshots while open.",
"Resize to desktop and narrow Chromium; scroll recovered Memory into view above fixed navigation; inspect all five final screenshots."
"Open row",
"Overlap refresh while initial request remains pending",
"Reset before response",
"Unmount before response",
"Resolve and reject deferred API reads in controlled order"
],
"notes": "Synthetic Desktop Chrome acceptance only, not touch/mobile-device, installed poller, FreeBSD reporter or alert-delivery proof. Base reproduced stale 100% after withdrawal; adapter repair passed final Chromium test without retries and pageerror assertion. Earlier fixture wrongly expected raw-facet total after withdrawal; useWorkloads maps canonical metrics only. Two selector/absent-section failures and first passing but bottom-nav-obscured screenshot run are retained, not counted as final visual acceptance. Final four transition screenshots show withdrawn section absent and recovered Total/Free in frame at both widths; initial 35% screenshot is table-only. Evidence: /var/lib/pulse-maintainer/queue/staging/20260907T215015Z-web-product/ (moves to completed): browser-base.log, browser-repair.log, browser-repair-selector.log, browser-canonical.log, browser-final.log, final-report/data/*.png. Browser command: pulse-heavy-run -- bash wrapper starting Vite 127.0.0.1:5187 then PLAYWRIGHT_BASE_URL=http://127.0.0.1:5187 playwright test tests/86-hybrid-memory-browser.spec.ts --project=chromium --retries=0; Vite stopped by wrapper trap."
"limitations": [
"Fixture lifecycle controls; not full Alerts route, installed acceptance, or notification delivery. PR1973 error accessor absent."
],
"command": "pulse-heavy-run -- node scripts/check-incident-request-ownership.mjs"
}
@@ -20,15 +20,11 @@ function deferred() {
return { promise, resolve, reject };
}
// Known lifecycle defects, reproduced against the supplied main source.
// it.fails is intentional: these are unresolved bugs, NOT passing acceptance.
// Convert each to ordinary it when repairing request ownership; a fixed behaviour
// makes an expected-failure test fail until its marker is removed. PR1973 also
// needs a stale resourceIncidentError regression when that accessor lands.
// Request ownership covers success, failure and loading writes independently.
describe('resource incident request ownership', () => {
beforeEach(() => vi.resetAllMocks());
it.fails('does not repopulate cleared history after reset', async () => {
it('does not repopulate cleared history after reset', async () => {
const pending = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource).mockReturnValueOnce(pending.promise);
const { result } = renderHook(useAlertResourceIncidentsState);
@@ -41,7 +37,7 @@ describe('resource incident request ownership', () => {
expect(result.resourceIncidentPanel()).toBeNull();
});
it.fails('keeps the newer same-resource result when requests finish backwards', async () => {
it('keeps the newer same-resource result when requests finish backwards', async () => {
const old = deferred();
const latest = [{ id: 'latest' }] as Incidents;
vi.mocked(AlertsAPI.getIncidentsForResource)
@@ -55,7 +51,7 @@ describe('resource incident request ownership', () => {
expect(result.resourceIncidents().host).toEqual(latest);
});
it.fails('does not clear newer loading state or report a superseded failure', async () => {
it('does not clear newer loading state or report a superseded failure', async () => {
const old = deferred();
const current = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource)
@@ -75,7 +71,7 @@ describe('resource incident request ownership', () => {
expect(result.resourceIncidentLoading().host).toBe(false);
});
it.fails('ignores failed reads after disposal', async () => {
it('ignores failed reads after disposal', async () => {
const pending = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource).mockReturnValueOnce(pending.promise);
const { result, cleanup } = renderHook(useAlertResourceIncidentsState);
@@ -86,6 +82,56 @@ describe('resource incident request ownership', () => {
expect(notificationStore.error).not.toHaveBeenCalled();
});
it('keeps a reopened request owned after an older reset-era failure', async () => {
const old = deferred();
const current = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource)
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const { result } = renderHook(useAlertResourceIncidentsState);
const load = result.openResourceIncidentPanel('host', 'Host', 'row');
result.resetResourceIncidentsState();
const reopened = result.openResourceIncidentPanel('host', 'Host', 'row');
old.reject(new Error('reset-era failure'));
await load;
expect(result.resourceIncidentLoading().host).toBe(true);
expect(notificationStore.error).not.toHaveBeenCalled();
current.resolve([]);
await reopened;
expect(result.resourceIncidents().host).toEqual([]);
expect(result.resourceIncidentLoading().host).toBe(false);
});
it('ignores an obsolete failure after the newer request succeeded', async () => {
const old = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource)
.mockReturnValueOnce(old.promise)
.mockResolvedValueOnce([]);
const { result } = renderHook(useAlertResourceIncidentsState);
const load = result.openResourceIncidentPanel('host', 'Host', 'row');
await result.refreshResourceIncidentPanel();
old.reject(new Error('obsolete failure'));
await load;
expect(notificationStore.error).not.toHaveBeenCalled();
expect(result.resourceIncidents().host).toEqual([]);
expect(result.resourceIncidentLoading().host).toBe(false);
});
it('ignores successful reads and new loads after disposal', async () => {
const pending = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource).mockReturnValueOnce(pending.promise);
const { result, cleanup } = renderHook(useAlertResourceIncidentsState);
const load = result.openResourceIncidentPanel('host', 'Host', 'row');
cleanup();
pending.resolve([]);
await load;
await result.refreshResourceIncidentPanel();
await result.openResourceIncidentPanel('other', 'Other', 'other-row');
expect(result.resourceIncidents()).toEqual({});
expect(result.resourceIncidentLoading()).toEqual({ host: true });
expect(AlertsAPI.getIncidentsForResource).toHaveBeenCalledTimes(1);
});
it('retains independent resource results and reports current failures', async () => {
const first = deferred();
vi.mocked(AlertsAPI.getIncidentsForResource)
@@ -1,4 +1,4 @@
import { createSignal } from 'solid-js';
import { createSignal, onCleanup } from 'solid-js';
import { AlertsAPI } from '@/api/alerts';
import { notificationStore } from '@/stores/notifications';
@@ -28,18 +28,35 @@ export function useAlertResourceIncidentsState() {
new Set(INCIDENT_EVENT_TYPES),
);
const loadResourceIncidents = async (resourceId: string, limit = 10) => {
if (!resourceId) return;
// Only the latest request for each resource owns its completion writes.
// Clearing the map invalidates pending reads without cancelling other resources.
const requests = new Map<string, symbol>();
let disposed = false;
onCleanup(() => {
disposed = true;
requests.clear();
});
const loadResourceIncidents = async (resourceId: string, limit = 10) => {
if (!resourceId || disposed) return;
const request = Symbol(resourceId);
requests.set(resourceId, request);
const ownsRequest = () => !disposed && requests.get(resourceId) === request;
setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: true }));
try {
const incidents = await AlertsAPI.getIncidentsForResource(resourceId, limit);
if (!ownsRequest()) return;
setResourceIncidents((prev) => ({ ...prev, [resourceId]: incidents }));
} catch (error) {
if (!ownsRequest()) return;
logger.error(getAlertResourceIncidentLoadFailure(), error);
notificationStore.error(getAlertResourceIncidentLoadFailure());
} finally {
setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: false }));
if (ownsRequest()) {
requests.delete(resourceId);
setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: false }));
}
}
};
@@ -48,7 +65,7 @@ export function useAlertResourceIncidentsState() {
resourceName: string,
rowKey: string,
) => {
if (!resourceId) return;
if (!resourceId || disposed) return;
// Clicking the same row's button again closes the panel, matching how the
// neighbouring Timeline button toggles its own expansion.
@@ -84,6 +101,7 @@ export function useAlertResourceIncidentsState() {
};
const resetResourceIncidentsState = () => {
requests.clear();
setResourceIncidentPanel(null);
setResourceIncidents({});
setResourceIncidentLoading({});
@@ -0,0 +1,185 @@
// Isolated real-browser component qualification; no installed backend or delivery claim.
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { chromium } from "@playwright/test";
import { resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
const fixture = `
import { render } from 'solid-js/web';
import { createSignal, Show } from 'solid-js';
import { AlertsAPI } from '/src/api/alerts';
import { notificationStore } from '/src/stores/notifications';
import { useAlertResourceIncidentsState } from '/src/features/alerts/useAlertResourceIncidentsState';
import { AlertResourceIncidentsPanel } from '/src/features/alerts/AlertResourceIncidentsPanel';
import '/src/index.css';
const pending = [];
const [errors, setErrors] = createSignal(0);
notificationStore.error = () => setErrors(n => n + 1);
AlertsAPI.getIncidentsForResource = () => new Promise((resolve, reject) => pending.push({resolve, reject}));
window.finish = (i, status) => status === 'error' ? pending[i].reject(new Error('scripted read failure')) : pending[i].resolve(status === 'empty' ? [] : [{id:status, message:status, level:'warning', status:'open', openedAt:new Date().toISOString(), events:[]}]);
window.count = () => pending.length;
function Panel() {
const s = useAlertResourceIncidentsState();
window.snapshot = () => ({incidents:s.resourceIncidents(), loading:s.resourceIncidentLoading()});
return <section><button onClick={() => s.openResourceIncidentPanel('host','Host','row')}>Open row</button><button onClick={s.refreshResourceIncidentPanel}>Overlap refresh</button><button onClick={s.resetResourceIncidentsState}>Reset</button><output style="display:block;overflow-wrap:anywhere" data-testid="state">{JSON.stringify(window.snapshot())}</output><AlertResourceIncidentsPanel state={s}/></section>;
}
function Fixture() {
const [mounted, setMounted] = createSignal(true);
return <main class="p-4"><h1>Incident lifecycle qualification fixture</h1><button onClick={() => setMounted(false)}>Unmount</button><output data-testid="errors">{errors()}</output><Show when={mounted()}><Panel/></Show></main>;
}
render(() => <Fixture/>, document.getElementById('root'));
`;
const server = await createServer({
root,
configFile: false,
optimizeDeps: {
noDiscovery: true,
entries: [],
esbuildOptions: { target: "esnext" },
},
esbuild: { target: "esnext" },
plugins: [
solid(),
{
name: "incident-fixture",
configureServer(s) {
s.middlewares.use((req, res, next) => {
if (req.url === "/qualification") {
res.setHeader("Content-Type", "text/html");
res.end(
'<div id="root"></div><script type="module" src="/incident-fixture.tsx"></script>',
);
} else next();
});
},
resolveId(id) {
if (id === "/incident-fixture.tsx") return id;
},
load(id) {
if (id === "/incident-fixture.tsx") return fixture;
},
},
],
resolve: { alias: { "@": resolve(root, "src") } },
server: { host: "127.0.0.1", port: 5198, strictPort: true },
});
let browser;
try {
await server.listen();
browser = await chromium.launch({ headless: true });
const output =
process.env.PULSE_BROWSER_OUTPUT || "/tmp/pulse-incident-ownership";
mkdirSync(output, { recursive: true });
let cases = 0;
for (const width of [1440, 390]) {
for (const scenario of [
"reset",
"reverse-success",
"obsolete-failure",
"failure-after-success",
"dispose-success",
"dispose-failure",
"current-failure",
]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto("http://127.0.0.1:5198/qualification");
await page.getByRole("button", { name: "Open row", exact: true }).click();
await page.waitForFunction(() => window.count?.() === 1);
if (scenario === "reset") {
await page.getByRole("button", { name: "Reset", exact: true }).click();
await page.evaluate(() => window.finish(0, "empty"));
} else if (scenario.startsWith("dispose")) {
await page
.getByRole("button", { name: "Unmount", exact: true })
.click();
await page.evaluate(
(s) => window.finish(0, s === "dispose-failure" ? "error" : "empty"),
scenario,
);
} else if (scenario === "current-failure") {
await page.evaluate(() => window.finish(0, "error"));
} else {
await page
.getByRole("button", { name: "Overlap refresh", exact: true })
.click();
await page.waitForFunction(() => window.count() === 2);
if (scenario === "obsolete-failure") {
await page.evaluate(() => window.finish(0, "error"));
await page.evaluate(
() => new Promise((r) => requestAnimationFrame(r)),
);
assert.equal(
(await page.evaluate(() => window.snapshot())).loading.host,
true,
);
assert.equal(await page.getByTestId("errors").textContent(), "0");
await page.evaluate(() => window.finish(1, "Latest incident"));
} else {
await page.evaluate(() => window.finish(1, "Latest incident"));
await page.getByText("Latest incident", { exact: true }).waitFor();
await page.evaluate(
(s) =>
window.finish(
0,
s === "failure-after-success" ? "error" : "Obsolete incident",
),
scenario,
);
}
}
await page.evaluate(
() =>
new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r)),
),
);
const snapshot = await page.evaluate(() => window.snapshot());
assert.equal(
await page.getByTestId("errors").textContent(),
scenario === "current-failure" ? "1" : "0",
);
if (scenario === "reset") {
assert.deepEqual(snapshot, { incidents: {}, loading: {} });
} else if (scenario.startsWith("dispose")) {
assert.deepEqual(snapshot, { incidents: {}, loading: { host: true } });
assert.equal(await page.locator("section").count(), 0);
} else if (scenario === "current-failure") {
assert.equal(snapshot.loading.host, false);
} else {
assert.equal(snapshot.incidents.host[0].id, "Latest incident");
assert.equal(snapshot.loading.host, false);
await page.getByText("Latest incident", { exact: true }).waitFor();
assert.equal(
await page.getByText("Obsolete incident", { exact: true }).count(),
0,
);
}
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.screenshot({
path: output + "/" + width + "-" + scenario + ".png",
});
await page.close();
cases++;
}
}
console.log(
JSON.stringify({
result: "passed",
cases,
viewports: [1440, 390],
scope:
"Real Chromium, real hook and incident panel, scripted API and fixture lifecycle controls; not installed application or delivery acceptance",
}),
);
} finally {
await browser?.close();
await server.close();
}
@@ -2464,6 +2464,7 @@ None yet.
"frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx",
"frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts",
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts",
"scripts/check-incident-request-ownership.mjs",
],
}
],