From 41030b2c789cde7bcf766b2b508e1bc1af2c88dc Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 05:32:33 +0300 Subject: [PATCH] =?UTF-8?q?test+docs(deploy):=20=D0=BF=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20reactive=20fail-closed=20guard=20+=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=D1=82=D1=8C=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=20=D0=BE=D0=BA=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=20(#499=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: интеграционный тест на reactive путь при storage, который ЧИТАЕТ, но не ПИШЕТ (quota / Safari private): getItem→null (бюджет доступен, проходим hasAutoReloaded), setItem→throw → markAutoReloaded()===false → guard на chunk-load-error-boundary.tsx:43 обязан отбить reload, иначе реактивный путь зациклил бы reload без сохранённого бюджета. Симметричный proactive случай уже покрыт guarded-reload.test.tsx. Мутация (снять guard :43) → тест краснеет. F2: формулировки "per session / this session" в version-coherence.ts и guarded-reload.tsx приведены к оконной модели ("per RELOAD_WINDOW_MS window" / "window budget available") — reload-guard.ts и тест восстановления после окна уже описывают именно окно, а не постоянный one-shot на сессию. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/user/guarded-reload.tsx | 17 +++++++------ .../user/reload-budget.integration.test.tsx | 25 +++++++++++++++++++ .../src/features/user/version-coherence.ts | 10 ++++---- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/apps/client/src/features/user/guarded-reload.tsx b/apps/client/src/features/user/guarded-reload.tsx index 82b81d14..eeffaa7d 100644 --- a/apps/client/src/features/user/guarded-reload.tsx +++ b/apps/client/src/features/user/guarded-reload.tsx @@ -87,11 +87,12 @@ function showReloadBanner(): void { * version and, on a real mismatch, show the banner and arm a guarded reload for * the next in-app navigation (variant C). * - * - real mismatch (first this session) → banner + arm navigation reload. The - * banner's "Update" button reloads immediately (same one-shot guard). The tab - * is NOT reloaded on visibility change. - * - auto-reload already used / storage error → banner only (no arm), so there is - * at most one automatic reload per session (loop safety). + * - real mismatch (window budget available) → banner + arm navigation reload. + * The banner's "Update" button reloads immediately (same shared window guard). + * The tab is NOT reloaded on visibility change. + * - auto-reload already used this window / storage error → banner only (no arm), + * so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop + * safety). * - in sync / unknown version → noop (fail-safe). */ export function triggerGuardedReload( @@ -120,11 +121,11 @@ export function triggerGuardedReload( lastClientVersion = clientVersion; if (action === "banner") { - // Entered banner-only (permanent skew, node oscillation, or spent - // auto-reload). Log for diagnosability; show the manual banner. + // Entered banner-only (permanent skew, node oscillation, or the window's + // auto-reload budget already spent). Log for diagnosability; show the banner. console.warn( `[version-coherence] server=${serverVersion} client=${clientVersion}: ` + - "auto-reload already spent this session — showing manual banner", + "auto-reload budget already spent this window — showing manual banner", ); showReloadBanner(); return; diff --git a/apps/client/src/features/user/reload-budget.integration.test.tsx b/apps/client/src/features/user/reload-budget.integration.test.tsx index 35485166..3ced255a 100644 --- a/apps/client/src/features/user/reload-budget.integration.test.tsx +++ b/apps/client/src/features/user/reload-budget.integration.test.tsx @@ -119,6 +119,31 @@ describe("shared window-based reload budget (invariant a)", () => { expect(reload).toHaveBeenCalledTimes(1); }); + it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => { + // getItem→null makes hasAutoReloaded() report the budget as available, so + // handleError passes the first guard and reaches `if (!markAutoReloaded()) + // return;`. setItem throws → the stamp cannot stick, so markAutoReloaded() + // returns false and that guard MUST bail — otherwise the reactive path would + // reload on every stale-chunk error with no persisted budget (an unguarded + // loop). This is the asymmetric gap: the proactive path's equivalent is + // covered by guarded-reload.test.tsx "does NOT reload when the flag write + // fails". + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + removeItem: () => {}, + clear: () => {}, + }); + try { + handleError(CHUNK_ERROR); + expect(reload).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("sessionStorage unavailable: neither path performs an unguarded reload", () => { // The real guard fails toward NOT reloading when storage throws. vi.stubGlobal("sessionStorage", { diff --git a/apps/client/src/features/user/version-coherence.ts b/apps/client/src/features/user/version-coherence.ts index ba99d757..0767ec33 100644 --- a/apps/client/src/features/user/version-coherence.ts +++ b/apps/client/src/features/user/version-coherence.ts @@ -10,9 +10,9 @@ export type AppVersionSocketPayload = { version: string }; * All inputs are injected (no globals, no side effects) so it is unit-testable * without a DOM or the build-time `APP_VERSION` global (undefined under vitest). * - * - `autoReloadUsed` = a session-wide automatic reload has already happened, - * so we must not auto-reload again (loop safety, shared with the reactive - * chunk-load boundary). + * - `autoReloadUsed` = an automatic reload has already happened within the + * current ~5-min window, so we must not auto-reload again (loop safety, + * shared window budget with the reactive chunk-load boundary). * * Returns: * - "noop" — do nothing (unknown version on either side, or already in sync). @@ -27,6 +27,6 @@ export function decideVersionAction(args: { const { serverVersion, clientVersion, autoReloadUsed } = args; if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act if (serverVersion === clientVersion) return "noop"; // in sync - if (autoReloadUsed) return "banner"; // one auto-reload per session already spent - return "reload"; // real mismatch, first time this session + if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent + return "reload"; // real mismatch, window budget available }