diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/context.md b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/context.md index f4e84af..30f6d02 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/context.md +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/context.md @@ -6,3 +6,5 @@ artifact: context # Context Leif reported repeated WebGL context loss and ResizeObserver undelivered notifications after the viewer/site adoption. Direct resize callbacks mutate the observed layout; lost contexts are retried on every draw. Slice zoom currently allocates the complete enlarged grid (3840 squared in a modest viewport). This repair is confined to browser drawables and scheduling. Native camera mathematics, editing semantics, parser/storage and trust policies remain unchanged. + +PR95 and site PR528 merged. Leif's continued Safari failure report keeps this approved repair active. The startup path failed to latch null/already-lost contexts before event delivery; resource creation also lacked null guards. The follow-up covers that same loss-suspension/restoration requirement, with actual Safari and deterministic startup/resource regressions. No new camera, loader, format or trust-policy scope is added. diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/plan.md b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/plan.md index 0ce79c2..017bc3e 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/plan.md +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/plan.md @@ -9,3 +9,5 @@ artifact: plan 2. Render a viewport-sized Slice bitmap over a virtual scroll surface, preserving coordinates and sharp glyphs. 3. Latch context loss, preserve application state and rebuild only after restoration. 4. Verify regressions and the repository gate, publish a feature PR, then import its tested commit in a site follow-up PR. + +5. Repair the startup failure/event gap and partial-resource initialization under the existing loss-suspension contract; verify actual Safari and import the tested follow-up in the site. diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/state.json b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/state.json index 3fdd67d..a2047bc 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/state.json +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/state.json @@ -11,7 +11,7 @@ "canonical_applied": true, "base_commit": "fc4944c134a8f351f3073bf4febcdebb87df2f81", "created_at": 1791664278, - "updated_at": 1791665863, + "updated_at": 1791668070, "affected_specs": [ "ThreeMDViewer" ], diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/tasks.md b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/tasks.md index 4ead236..4a632de 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/tasks.md +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/tasks.md @@ -10,3 +10,5 @@ artifact: tasks - [x] Publish upstream PR95 and import its exact tested revision in the site follow-up checkout; independent site gates and PR publication are owned by that site change. - [x] Add and verify every text catalog example, local binary/composition/folder inputs and desktop/phone size-boundary regressions requested by Leif. + +- [x] Verify the Safari startup/event gap, safe resource failure and actual native restoration. diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/testing.md b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/testing.md index ec19556..4ef8c9b 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/testing.md +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/testing.md @@ -16,3 +16,7 @@ Exercise Fit/16x, scroll and selected-cell painting/undo; rapid Slice/Cubes and ## Expanded viewer coverage The viewer suite now visits all 293 text examples across 48 axes through Cubes, Slice and Preview, opens all four binary samples through file input, retains embedded entries, and verifies four linked local-folder drafts. Desktop and phone boundary cases cover empty/single cells, 64-cell skinny grids, 4000/4096 occupied cells, 64/65 grid edges, 256/257 planes and the 1.5 MiB source limit. Unsupported Slice grids explain their refusal while preserving source and Preview. Details and actual live GitHub-folder/binary observations are in `docs/evidence/viewer-context/README.md`. + +Safari follow-up: reproduce null/already-lost startup contexts before loss-event delivery, 256 redraws, safe null resource creation, and genuine early loss/restoration. Verify actual Safari alongside Playwright; record the independent native observations and the unresolved hardware cause honestly. + +2026-10-10 Safari follow-up verification: complete Chromium/WebKit suite 256 passed without retries or skips (2 workers, 2.8 minutes), followed by the additional genuine early-loss test passing in both engines (2 checks). Native Safari 26.5.2 independently held at one context request while lost, retained Slice erase/Undo, and rebuilt exactly once after restoration. See docs/evidence/viewer-context/README.md for the proof boundaries. diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification-attempts.json b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification-attempts.json index efd7681..1c71e15 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification-attempts.json +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification-attempts.json @@ -72,6 +72,42 @@ "requirement_ids": [ "REQ-ThreeMDViewer-004" ] + }, + { + "timestamp": 1791668051, + "commit": "2cce498ae01a4376d55bb1de2291a08b19d73e27", + "contract_digest": "5a8d804bd8dfc53eff71bfb3730f3f1d2d69f478fa86df116d24bb2ba58727d4", + "execution_digest": "9878f72ac82aa4235a97ffd20a77e741a627082e032fd33d5d9167bb959452ca", + "workspace_digest": "139b08547f6a7c8bb8d38caa46a61fee966a48fad2ac98868c7f18faf3ba4ea9", + "passed": true, + "commands": [ + { + "command": "specsync check --spec ThreeMDViewer --strict", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-ThreeMDViewer-004" + ] + }, + { + "timestamp": 1791668066, + "commit": "7e71e559cd361690c90f2c7a524d51454faa2307", + "contract_digest": "5a8d804bd8dfc53eff71bfb3730f3f1d2d69f478fa86df116d24bb2ba58727d4", + "execution_digest": "9878f72ac82aa4235a97ffd20a77e741a627082e032fd33d5d9167bb959452ca", + "workspace_digest": "139b08547f6a7c8bb8d38caa46a61fee966a48fad2ac98868c7f18faf3ba4ea9", + "passed": true, + "commands": [ + { + "command": "specsync check --spec ThreeMDViewer --strict", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-ThreeMDViewer-004" + ] } ] } diff --git a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification.json b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification.json index 36b4134..7baa0a3 100644 --- a/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification.json +++ b/.specsync/changes/stabilize-viewer-canvas-resizing-and-webgl-context-recovery/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1791665859, - "commit": "b80bcf8219c3212590b8b064b7bd9c7883575562", + "timestamp": 1791668066, + "commit": "7e71e559cd361690c90f2c7a524d51454faa2307", "contract_digest": "5a8d804bd8dfc53eff71bfb3730f3f1d2d69f478fa86df116d24bb2ba58727d4", - "execution_digest": "1a5ce315857e99de27fc9a82ae456e8d4e588952cea98ab4b5479d752ddfd776", - "workspace_digest": "e0f8bc2d2e5987582bdd0a8f5464b5ec1f0329dc5b714bfede33f2f6fbe15b85", + "execution_digest": "9878f72ac82aa4235a97ffd20a77e741a627082e032fd33d5d9167bb959452ca", + "workspace_digest": "139b08547f6a7c8bb8d38caa46a61fee966a48fad2ac98868c7f18faf3ba4ea9", "passed": true, "commands": [ { diff --git a/docs/evidence/viewer-context/README.md b/docs/evidence/viewer-context/README.md index 3d72ffa..45f7a90 100644 --- a/docs/evidence/viewer-context/README.md +++ b/docs/evidence/viewer-context/README.md @@ -36,3 +36,16 @@ An isolated built-site viewer opened the live public GitHub Examples folder (307 The renderer remains byte-identical to implementation d2e875e. The expanded test file SHA-256 is `0c35ca8f3b8af09cde3b25a06450e1835c1b5373c19d018f360734f03cc32770`. The complete expanded suite passed all 242 tests on Chromium/WebKit with two workers in 2.7 minutes, without skips or retries, on 2026-10-10. Runtime bytes remain unchanged from d2e875e; the source hashes above distinguish the original test set from this expanded test set. + +## Safari startup follow-up (2026-10-10) + +The observations and source pin above describe the merged PR95 repair. Safari 26.5.2 still reproduced 256 `WebGL: context lost` console errors at the public viewer. The existing loss-event handler was insufficient when context creation failed or returned an already-lost context before the event reached the app. A controlled pre-fix test made 263 context requests during 256 redraws. The underlying intermittent Safari/GPU loss cause has not been established; subsequent fresh public loads also succeeded. + +The follow-up latches failure/loss directly during initialization and drawing, refuses null shader/program/vertex-array/buffer handles, and clears the loss latch only on actual restoration. No shader, camera, native app, file-format or loader behavior changed. A failed unavailable context makes one request for the page lifetime; a lost context resumes after restoration. Slice and Preview remain available while graphics are paused. + +- Complete browser suite: 256 passed on Chromium/WebKit, two workers, 2.8 minutes, no skips or retries. This includes all 293 text examples, binary/composition/folder inputs, size bounds and existing camera/picking/edit checks. +- A subsequently added genuine early-loss test passed in both engines (2 checks). It suppresses the app's loss-event delivery, forces a real loss via `WEBGL_lose_context`, issues 256 redraws, then restores: one initial request, one rebuild, exact source/camera/slice retained and no GPU errors after restoration. +- Native Safari 26.5.2 independently tested the same genuine startup-loss harness. While lost: one context request and zero draws. Slice erasure reduced the cube count 122 to 121; Undo restored 122 without another context request. After explicit graphics restoration: two total requests, one draw, one loss and one restoration, with the WebGL2 reference visible. Screenshot: [safari-startup-restored.png](safari-startup-restored.png). The diagnostic footer and restore button exist only in the temporary harness. +- Null startup context and already-lost startup context each stay at one request through 256 redraws, Slice, phone resizing, 16x and Expand. All five null GPU-resource cases stop safely with Slice/Preview usable and source preserved. + +Tested `web/viewer.html` SHA-256: `591ab297de99f35198abf1214490e6be8c211e553d192d315bc8ef0ec85a16fa`. Tested `uitests/viewer.spec.mjs` SHA-256: `ca0222441f219e230f8571d389c66430e35d33ce21c4b198812989332524dbff`. Native observations supplement the automated WebKit checks; they do not prove every Safari device or the deployed public revision. Current repository-gate/provenance outcomes are reported separately after the actual gates run. No human diff review, trusted signature or lifecycle closure is claimed for this follow-up. diff --git a/docs/evidence/viewer-context/safari-startup-restored.png b/docs/evidence/viewer-context/safari-startup-restored.png new file mode 100644 index 0000000..a718ed8 Binary files /dev/null and b/docs/evidence/viewer-context/safari-startup-restored.png differ diff --git a/specs/ThreeMDViewer/context.md b/specs/ThreeMDViewer/context.md index 847b3af..24b499b 100644 --- a/specs/ThreeMDViewer/context.md +++ b/specs/ThreeMDViewer/context.md @@ -72,3 +72,7 @@ README and `docs/VIEWER.md` provide GitHub-backed text, binary, composition and Resize observers queue a single animation-frame redraw. The Slice surface provides virtual scroll extents while its bitmap covers only visible cells, at capped display density. Context loss suspends GPU allocation/drawing until restoration; drafts, camera and slice remain application state. The expanded viewer regression set covers all catalog documents and supported local binary/composition/folder inputs, with explicit phone/desktop size boundaries. Preview supports prose documents; only matching rectangular sculpture grids qualify for Slice. Test expansion does not change the shared runtime or the site's d2e875e pin. + +## Safari startup follow-up + +Leif reported 256 context-loss messages after PR95 and the site import merged. Actual Safari 26.5.2 reproduced the loss on the public site; a fresh copy subsequently rendered, so the hardware/origin cause is not proven. Initialization previously returned on a null or already-lost context without latching it, allowing hundreds of getContext attempts before a loss event. Startup and resource failures now stop further allocations/draws, and restoration clears the cached error and rebuilds once. Shader, color, camera, picking, format and editing behavior remain unchanged. Native Safari evidence complements Playwright WebKit; a green WebKit run alone did not establish Safari stability. diff --git a/specs/ThreeMDViewer/tasks.md b/specs/ThreeMDViewer/tasks.md index 14810c6..467b749 100644 --- a/specs/ThreeMDViewer/tasks.md +++ b/specs/ThreeMDViewer/tasks.md @@ -55,3 +55,5 @@ spec: ThreeMDViewer.spec.md - [x] Add the guide to hosted documentation navigation and the selected-docs bundle. - [x] Add and verify every text catalog example, local binary/composition/folder inputs and desktop/phone size-boundary regressions requested by Leif. + +- [x] Stop startup context retries before loss-event delivery, guard unavailable GPU resources, and verify genuine early loss/restoration in both automated engines and native Safari. diff --git a/specs/ThreeMDViewer/testing.md b/specs/ThreeMDViewer/testing.md index 11b585c..f77d8f7 100644 --- a/specs/ThreeMDViewer/testing.md +++ b/specs/ThreeMDViewer/testing.md @@ -92,3 +92,9 @@ High-zoom scrolled-cell painting/undo and genuine WEBGL_lose_context loss/restor The viewer suite now visits all 293 text examples across 48 axes through Cubes, Slice and Preview, opens all four binary samples through file input, retains embedded entries, and verifies four linked local-folder drafts. Desktop and phone boundary cases cover empty/single cells, 64-cell skinny grids, 4000/4096 occupied cells, 64/65 grid edges, 256/257 planes and the 1.5 MiB source limit. Unsupported Slice grids explain their refusal while preserving source and Preview. Details and actual live GitHub-folder/binary observations are in `docs/evidence/viewer-context/README.md`. 2026-10-10 expanded complete suite: 242 Chromium/WebKit tests passed in 2.7 minutes, two workers, no skips or retries. The four new cases separately passed in both engines. These are agent checks, not human acceptance or live deployment. + +## Safari startup follow-up + +Simulate null and already-lost context creation without a delivered loss event, issue 256 redraws and change views/viewport: one getContext attempt and unchanged failed bitmap. Null shader/program/vertex-array/mesh/instance allocations must stop without invalid GPU calls and preserve Slice/Preview. A genuine WEBGL_lose_context startup loss suppresses the application's loss event, then restoration rebuilds once with source, pose and slice preserved. Inspect actual Safari startup, Slice editing/Undo during loss and restoration, alongside the complete cross-browser suite. + +2026-10-10 Safari follow-up verification: complete Chromium/WebKit suite 256 passed without retries or skips (2 workers, 2.8 minutes), followed by the additional genuine early-loss test passing in both engines (2 checks). Native Safari 26.5.2 independently held at one context request while lost, retained Slice erase/Undo, and rebuilt exactly once after restoration. See docs/evidence/viewer-context/README.md for the proof boundaries. diff --git a/uitests/viewer.spec.mjs b/uitests/viewer.spec.mjs index 2a73623..5c2ea47 100644 --- a/uitests/viewer.spec.mjs +++ b/uitests/viewer.spec.mjs @@ -1677,3 +1677,126 @@ test("a local linked folder resolves entries and preserves each edited draft", a expect(await page.evaluate(()=>window.threeMd.source)).toBe(original+"\nFolder draft marker"); } }); + +for (const startup of ["missing", "already-lost"]) { + test(`WebGL ${startup} at startup makes one context request across repeated redraws`, async ({ page }) => { + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.addInitScript(startup => { + window.__startupContextCalls = 0; + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + if (this.id !== "cubeCanvas" || type !== "webgl2") return original.call(this, type, ...args); + window.__startupContextCalls++; + // Drivers may refuse creation or return a lost context before sending any event. + return startup === "missing" ? null : { isContextLost: () => true }; + }; + }, startup); + await page.goto("/viewer.html"); + await viewerReady(page); + const source = await page.evaluate(() => window.threeMd.source); + await page.evaluate(() => { + const canvas = document.getElementById("cubeCanvas"); + for (let index = 0; index < 256; index++) canvas.dispatchEvent(new Event("threemd-cubes")); + }); + await expect(page.locator("#cubeCanvas")).toHaveAttribute("data-renderer", startup === "missing" ? "none" : "lost"); + expect(await page.evaluate(() => window.__startupContextCalls)).toBe(1); + const bitmap = await page.locator("#cubeCanvas").evaluate(canvas => [canvas.width, canvas.height]); + await page.locator("#sliceTab").click(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('#sliceZoom [data-zoom="16"]').click(); + await page.locator("#sliceExpand").click(); + expect(await page.evaluate(() => window.__startupContextCalls)).toBe(1); + expect(await page.locator("#cubeCanvas").evaluate(canvas => [canvas.width, canvas.height])).toEqual(bitmap); + await page.locator("#previewTab").click(); + expect(await page.evaluate(() => window.threeMd.source)).toBe(source); + expect(errors).toEqual([]); + }); +} + +for (const resource of ["shader", "program", "vertex-array", "mesh-buffer", "instance-buffer"]) { + test(`WebGL ${resource} allocation failure stops setup without invalid GPU calls`, async ({ page }) => { + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("console", message => { if (message.type() === "error") errors.push(message.text()); }); + await page.addInitScript(resource => { + window.__allocationContextCalls = 0; + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + const context = original.call(this, type, ...args); + if (this.id !== "cubeCanvas" || type !== "webgl2" || !context) return context; + window.__allocationContextCalls++; + const method = { shader: "createShader", program: "createProgram", "vertex-array": "createVertexArray", + "mesh-buffer": "createBuffer", "instance-buffer": "createBuffer" }[resource]; + const create = context[method].bind(context); + let calls = 0; + context[method] = (...args) => ++calls === (resource === "instance-buffer" ? 2 : 1) ? null : create(...args); + return context; + }; + }, resource); + await page.goto("/viewer.html"); + await viewerReady(page); + await expect(page.locator("#cubeCanvas")).toHaveAttribute("data-renderer", "none"); + await expect(page.locator("#cubeCanvas")).toHaveAttribute("data-gl-error", /unavailable/); + const source = await page.evaluate(() => window.threeMd.source); + await page.evaluate(() => { + for (let index = 0; index < 256; index++) document.getElementById("cubeCanvas").dispatchEvent(new Event("threemd-cubes")); + }); + expect(await page.evaluate(() => window.__allocationContextCalls)).toBe(1); + await page.locator("#sliceTab").click(); + await expect(page.locator("#sliceWorkspace")).toBeVisible(); + await page.locator("#previewTab").click(); + expect(await page.evaluate(() => window.threeMd.source)).toBe(source); + expect(errors).toEqual([]); + }); +} + +test("a genuine startup loss pauses before its event and rebuilds once after restoration", async ({ page }) => { + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.addInitScript(() => { + window.__earlyContextCalls = 0; + let first = true; + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + const context = original.call(this, type, ...args); + if (this.id !== "cubeCanvas" || type !== "webgl2" || !context) return context; + window.__earlyContextCalls++; + if (first) { + first = false; + window.__earlyLoss = context.getExtension("WEBGL_lose_context"); + // Simulate a driver that loses its context before delivering the app's event. + this.addEventListener("webglcontextlost", event => { + event.preventDefault(); + event.stopImmediatePropagation(); + window.__earlyLossDelivered = true; + }, { capture: true, once: true }); + window.__earlyLoss?.loseContext(); + } + return context; + }; + }); + await page.goto("/viewer.html"); + await viewerReady(page); + expect(await page.evaluate(() => Boolean(window.__earlyLoss))).toBe(true); + await expect(page.locator("#cubeCanvas")).toHaveAttribute("data-renderer", "lost"); + await expect.poll(() => page.evaluate(() => window.__earlyLossDelivered)).toBe(true); + const source = await page.evaluate(() => window.threeMd.source); + await page.locator("#sliceTab").click(); + await page.locator("#sliceList button").nth(1).click(); + await page.evaluate(() => { + for (let index = 0; index < 256; index++) document.getElementById("cubeCanvas").dispatchEvent(new Event("threemd-cubes")); + }); + expect(await page.evaluate(() => window.__earlyContextCalls)).toBe(1); + const pose = await page.locator("#cubeCanvas").evaluate(canvas => + [canvas.dataset.yaw, canvas.dataset.pitch, canvas.dataset.rotation, canvas.dataset.panX, canvas.dataset.panY]); + await page.evaluate(() => window.__earlyLoss.restoreContext()); + await expect(page.locator("#cubeCanvas")).toHaveAttribute("data-renderer", "webgl2"); + expect(await page.evaluate(() => window.__earlyContextCalls)).toBe(2); + expect(await page.evaluate(() => document.getElementById("cubeCanvas").__cubeGl.getError())).toBe(0); + expect(await page.evaluate(() => window.threeMd.source)).toBe(source); + expect(await page.locator("#cubeCanvas").evaluate(canvas => + [canvas.dataset.yaw, canvas.dataset.pitch, canvas.dataset.rotation, canvas.dataset.panX, canvas.dataset.panY])).toEqual(pose); + await expect(page.locator("#sliceHeading")).toHaveText("Slice 2 of 3"); + expect(errors).toEqual([]); +}); diff --git a/web/viewer.html b/web/viewer.html index 7bf6067..568607d 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -1881,25 +1881,39 @@