Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"canonical_applied": true,
"base_commit": "fc4944c134a8f351f3073bf4febcdebb87df2f81",
"created_at": 1791664278,
"updated_at": 1791665863,
"updated_at": 1791668070,
"affected_specs": [
"ThreeMDViewer"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
13 changes: 13 additions & 0 deletions docs/evidence/viewer-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions specs/ThreeMDViewer/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions specs/ThreeMDViewer/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions specs/ThreeMDViewer/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
123 changes: 123 additions & 0 deletions uitests/viewer.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
await writeFile(target, text);
}
await page.locator("#folderInput").setInputFiles(directory);
await expect(page.locator("#fileList button")).toHaveCount(Object.keys(files).length);

Check failure on line 16 in uitests/viewer.spec.mjs

View workflow job for this annotation

GitHub Actions / Interactive lab (Chromium + WebKit)

[webkit] › viewer.spec.mjs:1170:3 › viewer & editor (viewer.html) › linked entries and file navigation share the same draft

1) [webkit] › viewer.spec.mjs:1170:3 › viewer & editor (viewer.html) › linked entries and file navigation share the same draft Error: expect(locator).toHaveCount(expected) failed Locator: locator('#fileList button') Expected: 4 Received: undefined 14 | } 15 | await page.locator("#folderInput").setInputFiles(directory); > 16 | await expect(page.locator("#fileList button")).toHaveCount(Object.keys(files).length); | ^ 17 | } finally { await rm(directory, { recursive: true, force: true }); } 18 | } 19 | const openedFile = (page, name) => page.locator("#fileList button").filter({ has: page.locator(".file-name", { hasText: name }) }); at openCollection (/home/runner/work/3md/3md/uitests/viewer.spec.mjs:16:52) at /home/runner/work/3md/3md/uitests/viewer.spec.mjs:1177:5
} finally { await rm(directory, { recursive: true, force: true }); }
}
const openedFile = (page, name) => page.locator("#fileList button").filter({ has: page.locator(".file-name", { hasText: name }) });
Expand Down Expand Up @@ -1677,3 +1677,126 @@
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([]);
});
Loading
Loading