diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index beb0cdbce..9bc7d0bf2 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e909142fc..2ab05ee14 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 59ad6aaba..574087ced 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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" ] }, { diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c211fb630..95e9f413d 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" } diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx index 202dd071f..9a28d6207 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx @@ -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) diff --git a/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts b/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts index 6bd7ec971..90db2d125 100644 --- a/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts +++ b/frontend-modern/src/features/alerts/useAlertResourceIncidentsState.ts @@ -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(); + 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({}); diff --git a/scripts/check-incident-request-ownership.mjs b/scripts/check-incident-request-ownership.mjs new file mode 100644 index 000000000..02e79df46 --- /dev/null +++ b/scripts/check-incident-request-ownership.mjs @@ -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
{JSON.stringify(window.snapshot())}
; +} +function Fixture() { + const [mounted, setMounted] = createSignal(true); + return

Incident lifecycle qualification fixture

{errors()}
; +} +render(() => , 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( + '
', + ); + } 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(); +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 95db2565f..aeb240c1a 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -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", ], } ],