diff --git a/.changeset/brave-workers-fallback.md b/.changeset/brave-workers-fallback.md new file mode 100644 index 00000000..540d4205 --- /dev/null +++ b/.changeset/brave-workers-fallback.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": patch +--- + +Fall back observably to the main-thread renderer when a paint worker cannot initialize, cannot create a 2D context, or loses its context. The public worker acknowledgement type now includes `ready` and `fatal` lifecycle messages. diff --git a/docs/src/content/docs/api/core-worker.md b/docs/src/content/docs/api/core-worker.md index 5cabfba6..5f1f0fed 100644 --- a/docs/src/content/docs/api/core-worker.md +++ b/docs/src/content/docs/api/core-worker.md @@ -24,5 +24,5 @@ Source entry: `packages/core/src/worker.ts` ### Types 1
-WorkerAcknowledgementAcknowledgement posted back to the sender after a frame actually painted. +WorkerAcknowledgementLifecycle and frame acknowledgements posted back to the sender.
diff --git a/docs/src/content/docs/api/core-worker/create-worker-message-handler.md b/docs/src/content/docs/api/core-worker/create-worker-message-handler.md index ce413af3..e2286873 100644 --- a/docs/src/content/docs/api/core-worker/create-worker-message-handler.md +++ b/docs/src/content/docs/api/core-worker/create-worker-message-handler.md @@ -11,7 +11,7 @@ without booting a browser Worker.
Package
@sheetwrite/core/worker
-
Source
packages/core/src/worker.ts#L256
+
Source
packages/core/src/worker.ts#L259
## Declaration diff --git a/docs/src/content/docs/api/core-worker/worker-acknowledgement.md b/docs/src/content/docs/api/core-worker/worker-acknowledgement.md index 589fafc7..ade04cd5 100644 --- a/docs/src/content/docs/api/core-worker/worker-acknowledgement.md +++ b/docs/src/content/docs/api/core-worker/worker-acknowledgement.md @@ -1,29 +1,41 @@ --- title: "WorkerAcknowledgement | @sheetwrite/core/worker" -description: "Acknowledgement posted back to the sender after a frame actually painted." +description: "Lifecycle and frame acknowledgements posted back to the sender." ---
@sheetwrite/core/workertype
-Acknowledgement posted back to the sender after a frame actually painted. +Lifecycle and frame acknowledgements posted back to the sender.
Package
@sheetwrite/core/worker
Source
packages/core/src/worker.ts#L249
-## Members 1 +## Variants 3 -
+
+
-
-type +```ts generated +{ type: "ready" } +``` + +
+
```ts generated -type: "painted" +{ type: "fatal"; reason: string } ``` - +
+
+ +```ts generated +{ type: "painted" } +``` + +
## Declaration @@ -32,9 +44,17 @@ type: "painted" View full TypeScript declaration ```ts generated -export type WorkerAcknowledgement = { - type: "painted"; -}; +export type WorkerAcknowledgement = + | { + type: "ready"; + } + | { + type: "fatal"; + reason: string; + } + | { + type: "painted"; + }; ``` diff --git a/docs/src/generated/docs-contract.json b/docs/src/generated/docs-contract.json index ada5e87d..c3dd167c 100644 --- a/docs/src/generated/docs-contract.json +++ b/docs/src/generated/docs-contract.json @@ -25,7 +25,7 @@ "entryPointCount": 18, "exportCount": 468, "symbolPageCount": 468, - "apiSha256": "9cc3fe343edebb80e5eb431b802655092d5ccd02d94ab4dfab76127c312d6192", + "apiSha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb", "compatibilitySha256": "3dc63d06901349f4edd5b2b5bc332b87cdbecacc54d303c85eb2b0b1354ed01a", "formulaContractSha256": "c19bbb023fd08bd378ca2d0b82761fa1b203e29f93a5d4b32bf4a2e6ce4affaf" } diff --git a/docs/src/generated/public-api.json b/docs/src/generated/public-api.json index bb518e87..075a1607 100644 --- a/docs/src/generated/public-api.json +++ b/docs/src/generated/public-api.json @@ -6610,7 +6610,7 @@ "owners": [ "src/worker.ts" ], - "source": "src/worker.ts#L256", + "source": "src/worker.ts#L259", "jsDocTags": [], "documentation": "Build the worker-side protocol handler. Keeping the mutable render state\ninside the returned closure lets tests exercise the real message contract\nwithout booting a browser Worker.", "memberDocs": [] @@ -6618,13 +6618,13 @@ { "name": "WorkerAcknowledgement", "kind": "type", - "signature": "export type WorkerAcknowledgement = { type: \"painted\" };", + "signature": "export type WorkerAcknowledgement = | { type: \"ready\" } | { type: \"fatal\"; reason: string } | { type: \"painted\" };", "owners": [ "src/worker.ts" ], "source": "src/worker.ts#L249", "jsDocTags": [], - "documentation": "Acknowledgement posted back to the sender after a frame actually painted.", + "documentation": "Lifecycle and frame acknowledgements posted back to the sender.", "memberDocs": [] } ] diff --git a/packages/core/src/worker-renderer.ts b/packages/core/src/worker-renderer.ts index 125434ac..c125faae 100644 --- a/packages/core/src/worker-renderer.ts +++ b/packages/core/src/worker-renderer.ts @@ -42,6 +42,9 @@ interface SharedRegion { generation: number; } +// Cold module loading and OffscreenCanvas startup can take seconds on slower +// devices; ten seconds tolerates that path without leaving a blank grid forever. +const WORKER_READY_TIMEOUT_MS = 10_000; const SHARED_REGION_COUNT = 2; const SHARED_HEADER_INTS = 2; const SHARED_HEADER_BYTES = SHARED_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT; @@ -114,6 +117,7 @@ export class WorkerRenderer implements Renderer { private readonly sharedRegions: Array = new Array(SHARED_REGION_COUNT); private failed = false; private frameGeneration = 0; + private readinessTimeout: number | undefined; constructor( private readonly workerUrl?: string | URL, @@ -140,6 +144,9 @@ export class WorkerRenderer implements Renderer { try { const offscreen = canvas.transferControlToOffscreen(); + this.readinessTimeout = window.setTimeout(() => { + this.fail(new Error("Sheetwrite: Paint worker did not become ready")); + }, WORKER_READY_TIMEOUT_MS); worker.postMessage({ type: "init", canvas: offscreen, theme }, [offscreen]); host.appendChild(canvas); this.canvas = canvas; @@ -298,6 +305,8 @@ export class WorkerRenderer implements Renderer { } destroy(): void { + window.clearTimeout(this.readinessTimeout); + this.readinessTimeout = undefined; const worker = this.worker; if (worker) { worker.removeEventListener("error", this.onWorkerError); @@ -312,31 +321,45 @@ export class WorkerRenderer implements Renderer { } private readonly onWorkerError = (event: ErrorEvent): void => { - if (this.failed) return; - this.failed = true; event.preventDefault(); const error = event.error instanceof Error ? event.error : new Error(event.message || "Sheetwrite: Worker renderer failed to load"); - const onFailure = this.onFailure; - this.destroy(); - onFailure?.(error); + this.fail(error); }; private readonly onWorkerMessage = (event: MessageEvent): void => { - if ( - event.data === null || - typeof event.data !== "object" || - !("type" in event.data) || - event.data.type !== "painted" - ) { - return; + const data = event.data; + if (data === null || typeof data !== "object" || !("type" in data)) return; + switch (data.type) { + case "ready": + window.clearTimeout(this.readinessTimeout); + this.readinessTimeout = undefined; + break; + case "fatal": { + const reason = + "reason" in data && typeof data.reason === "string" + ? data.reason + : "Sheetwrite: Paint worker reported a fatal failure"; + this.fail(new Error(reason)); + break; + } + case "painted": + this.frameGeneration++; + if (this.canvas) this.canvas.dataset.workerFrame = String(this.frameGeneration); + break; } - this.frameGeneration++; - if (this.canvas) this.canvas.dataset.workerFrame = String(this.frameGeneration); }; + private fail(error: unknown): void { + if (this.failed) return; + this.failed = true; + const onFailure = this.onFailure; + this.destroy(); + onFailure?.(error); + } + private canUseSharedMemory(): boolean { return ( typeof SharedArrayBuffer === "function" && diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index 96f5e1c6..cfba9468 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -245,8 +245,11 @@ function paintPanesFrame(state: WorkerRuntimeState, msg: PanesMessage): boolean return true; } -/** Acknowledgement posted back to the sender after a frame actually painted. */ -export type WorkerAcknowledgement = { type: "painted" }; +/** Lifecycle and frame acknowledgements posted back to the sender. */ +export type WorkerAcknowledgement = + | { type: "ready" } + | { type: "fatal"; reason: string } + | { type: "painted" }; /** * Build the worker-side protocol handler. Keeping the mutable render state @@ -271,17 +274,36 @@ export function createWorkerMessageHandler( const acknowledgeFrame = (painted: boolean): void => { if (painted) postAcknowledgement({ type: "painted" }); }; + let fatal = false; + const reportFatal = (reason: string): void => { + if (fatal) return; + fatal = true; + postAcknowledgement({ type: "fatal", reason }); + }; return (input: unknown): void => { if (input === null || typeof input !== "object" || !("type" in input)) return; const msg = input as WorkerMessage; switch (msg.type) { - case "init": + case "init": { state.canvas = msg.canvas; state.ctx = state.canvas.getContext("2d", { alpha: false }); state.theme = msg.theme; state.lastViewport = null; + if (!state.ctx) { + reportFatal("Sheetwrite: Paint worker could not acquire a 2D context"); + break; + } + // A lost canvas invalidates the previous frame assumed by scroll blits; + // falling back is safer than attempting restoration from stale pixels. + state.canvas.addEventListener( + "contextlost", + () => reportFatal("Sheetwrite: Paint worker lost its 2D context"), + { once: true }, + ); + postAcknowledgement({ type: "ready" }); break; + } case "layout": state.layout = msg.layout; state.lastViewport = null; diff --git a/packages/core/test/worker-fallback.test.ts b/packages/core/test/worker-fallback.test.ts index 7dfafb3c..663e86a5 100644 --- a/packages/core/test/worker-fallback.test.ts +++ b/packages/core/test/worker-fallback.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "bun:test"; import { GridImpl, initSheetwrite } from "../src/grid.js"; import { installCanvasTestStubs } from "../src/testing.js"; import type { GridEvents } from "../src/types.js"; +import { createWorkerMessageHandler } from "../src/worker.js"; import { makeColumnarData, makeWorkbook } from "./fixtures.js"; beforeAll(async () => { @@ -217,6 +218,140 @@ describe("worker renderer fallback observability", () => { } }); + it("falls back exactly once when the worker cannot acquire a 2D context", async () => { + const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker"); + const transferDescriptor = Object.getOwnPropertyDescriptor( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + ); + let terminations = 0; + + class NullContextWorker extends EventTarget { + private readonly handleMessage = createWorkerMessageHandler((message) => { + this.dispatchEvent(new MessageEvent("message", { data: message })); + }); + + postMessage(message: unknown): void { + queueMicrotask(() => this.handleMessage(message)); + } + + terminate(): void { + terminations += 1; + } + } + + const offscreen = new EventTarget(); + Object.defineProperties(offscreen, { + width: { value: 0, writable: true }, + height: { value: 0, writable: true }, + getContext: { value: () => null }, + }); + Object.defineProperty(globalThis, "Worker", { + configurable: true, + value: NullContextWorker, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", { + configurable: true, + value: () => offscreen, + }); + + try { + const host = mountHost(); + const events: Array = []; + const grid = new GridImpl(host, { + workbook: makeWorkbook(5), + data: makeColumnarData(5), + renderer: "worker", + workerUrl: "/worker-without-context.js", + }); + grid.on("renderer-fallback", (event) => events.push(event)); + await Promise.resolve(); + await Promise.resolve(); + + expect(grid.rendererKind()).toBe("canvas"); + expect(host.querySelectorAll("canvas")).toHaveLength(1); + expect(events).toHaveLength(1); + expect(terminations).toBe(1); + + grid.destroy(); + } finally { + if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor); + else Reflect.deleteProperty(globalThis, "Worker"); + if (transferDescriptor) { + Object.defineProperty( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + transferDescriptor, + ); + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen"); + } + } + }); + + it("falls back exactly once when the worker never acknowledges initialization", async () => { + jest.useFakeTimers(); + const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker"); + const transferDescriptor = Object.getOwnPropertyDescriptor( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + ); + let terminations = 0; + + class SilentWorker extends EventTarget { + postMessage(): void {} + + terminate(): void { + terminations += 1; + } + } + + Object.defineProperty(globalThis, "Worker", { + configurable: true, + value: SilentWorker, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", { + configurable: true, + value: () => new EventTarget(), + }); + + try { + const host = mountHost(); + const events: Array = []; + const grid = new GridImpl(host, { + workbook: makeWorkbook(5), + data: makeColumnarData(5), + renderer: "worker", + workerUrl: "/worker-that-never-replies.js", + }); + grid.on("renderer-fallback", (event) => events.push(event)); + expect(grid.rendererKind()).toBe("worker"); + + jest.advanceTimersByTime(30_000); + await Promise.resolve(); + + expect(grid.rendererKind()).toBe("canvas"); + expect(host.querySelectorAll("canvas")).toHaveLength(1); + expect(events).toHaveLength(1); + expect(terminations).toBe(1); + + grid.destroy(); + } finally { + jest.useRealTimers(); + if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor); + else Reflect.deleteProperty(globalThis, "Worker"); + if (transferDescriptor) { + Object.defineProperty( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + transferDescriptor, + ); + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen"); + } + } + }); + it("reports canvas and emits nothing for the default renderer", async () => { const workbook = makeWorkbook(5); const events: unknown[] = []; diff --git a/packages/core/test/worker-renderer.test.ts b/packages/core/test/worker-renderer.test.ts index 69fe682e..37c2297f 100644 --- a/packages/core/test/worker-renderer.test.ts +++ b/packages/core/test/worker-renderer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, jest } from "bun:test"; import type { PanePaint, RenderLayout, Theme, VisibleWindowView } from "../src/types.js"; import { createWorkerMessageHandler } from "../src/worker.js"; import { WorkerRenderer } from "../src/worker-renderer.js"; @@ -43,6 +43,23 @@ function latestWorker(): RecordingWorker { return worker; } +const TEST_THEME: Theme = { + font: "12px sans-serif", + bg: "#fff", + fg: "#111", + gridLine: "#ddd", + headerBg: "#eee", + headerFg: "#222", + selection: "#def", + selectionBorder: "#08f", + rowHeight: 20, + headerHeight: 24, + rowHeaderWidth: 40, + searchMatch: "#ff0", + searchActiveMatch: "#fa0", + highlight: "#cfc", +}; + interface SharedPaintPost { type: "paintPackedShared"; shared: { @@ -436,17 +453,60 @@ describe("WorkerRenderer", () => { expect(view.stringPoolUpdateIds).toHaveLength(1); }); + it("reports initialization failure and one fatal context-loss outcome", () => { + const nullContextAcknowledgements: unknown[] = []; + const nullContextCanvas = new EventTarget(); + Object.defineProperties(nullContextCanvas, { + width: { value: 0, writable: true }, + height: { value: 0, writable: true }, + getContext: { value: () => null }, + }); + createWorkerMessageHandler((message) => nullContextAcknowledgements.push(message))({ + type: "init", + canvas: nullContextCanvas, + theme: TEST_THEME, + }); + expect(nullContextAcknowledgements).toEqual([ + { + type: "fatal", + reason: "Sheetwrite: Paint worker could not acquire a 2D context", + }, + ]); + + const contextLossAcknowledgements: unknown[] = []; + const contextLossCanvas = new EventTarget(); + Object.defineProperties(contextLossCanvas, { + width: { value: 0, writable: true }, + height: { value: 0, writable: true }, + getContext: { value: () => ({}) }, + }); + createWorkerMessageHandler((message) => contextLossAcknowledgements.push(message))({ + type: "init", + canvas: contextLossCanvas, + theme: TEST_THEME, + }); + contextLossCanvas.dispatchEvent(new Event("contextlost")); + contextLossCanvas.dispatchEvent(new Event("contextlost")); + contextLossCanvas.dispatchEvent(new Event("contextrestored")); + expect(contextLossAcknowledgements).toEqual([ + { type: "ready" }, + { type: "fatal", reason: "Sheetwrite: Paint worker lost its 2D context" }, + ]); + }); + it("round-trips sender lifecycle payloads through the worker handler before acknowledging", () => { + jest.useFakeTimers(); const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker"); const transferDescriptor = Object.getOwnPropertyDescriptor( HTMLCanvasElement.prototype, "transferControlToOffscreen", ); - const offscreen = { - width: 0, - height: 0, - getContext: () => ({}), - } as unknown as OffscreenCanvas; + const offscreen = new EventTarget(); + Object.defineProperties(offscreen, { + width: { value: 0, writable: true }, + height: { value: 0, writable: true }, + getContext: { value: () => ({}) }, + }); Object.defineProperty(globalThis, "Worker", { configurable: true, value: RecordingWorker, @@ -456,7 +516,8 @@ describe("WorkerRenderer", () => { value: () => offscreen, }); - const renderer = new WorkerRenderer("/worker.js"); + const failures: unknown[] = []; + const renderer = new WorkerRenderer("/worker.js", {}, (error) => failures.push(error)); try { const host = document.createElement("div"); const layout: RenderLayout = { @@ -465,22 +526,7 @@ describe("WorkerRenderer", () => { headerHeight: 24, totalRows: 0, }; - const theme: Theme = { - font: "12px sans-serif", - bg: "#fff", - fg: "#111", - gridLine: "#ddd", - headerBg: "#eee", - headerFg: "#222", - selection: "#def", - selectionBorder: "#08f", - rowHeight: 20, - headerHeight: 24, - rowHeaderWidth: 40, - searchMatch: "#ff0", - searchActiveMatch: "#fa0", - highlight: "#cfc", - }; + const theme = TEST_THEME; latestConstructedWorker = null; renderer.mount(host, theme); @@ -511,20 +557,80 @@ describe("WorkerRenderer", () => { for (const { message } of worker.messages.slice(0, -1)) { handleWorkerMessage(message); } - expect(acknowledgements).toEqual([]); + expect(acknowledgements).toEqual([{ type: "ready" }]); + jest.advanceTimersByTime(30_000); + expect(failures).toHaveLength(0); expect(canvas.dataset.workerFrame).toBe("0"); handleWorkerMessage(paintPayload); - expect(acknowledgements).toEqual([{ type: "painted" }]); + expect(acknowledgements).toEqual([{ type: "ready" }, { type: "painted" }]); expect(canvas.dataset.workerFrame).toBe("1"); renderer.destroy(); handleWorkerMessage(worker.messages.at(-1)?.message); - expect(acknowledgements).toEqual([{ type: "painted" }]); + expect(acknowledgements).toEqual([{ type: "ready" }, { type: "painted" }]); expect(worker.terminations).toBe(1); expect(host.querySelector("canvas")).toBeNull(); } finally { renderer.destroy(); + jest.useRealTimers(); + if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor); + else Reflect.deleteProperty(globalThis, "Worker"); + if (transferDescriptor) { + Object.defineProperty( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + transferDescriptor, + ); + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen"); + } + } + }); + + it("guards duplicate fatal signals and cancels readiness failure on destroy", () => { + jest.useFakeTimers(); + const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker"); + const transferDescriptor = Object.getOwnPropertyDescriptor( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + ); + Object.defineProperty(globalThis, "Worker", { + configurable: true, + value: RecordingWorker, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", { + configurable: true, + value: () => new EventTarget(), + }); + + const failures: unknown[] = []; + const renderer = new WorkerRenderer("/worker.js", {}, (error) => failures.push(error)); + try { + latestConstructedWorker = null; + renderer.mount(document.createElement("div"), TEST_THEME); + const worker = latestWorker(); + worker.emitMessage({ type: "fatal", reason: "context unavailable" }); + worker.emitMessage({ type: "fatal", reason: "duplicate fatal" }); + worker.dispatchEvent(new Event("error", { cancelable: true })); + jest.advanceTimersByTime(30_000); + expect(failures).toHaveLength(1); + expect(worker.terminations).toBe(1); + + const destroyedFailures: unknown[] = []; + const destroyed = new WorkerRenderer("/worker.js", {}, (error) => + destroyedFailures.push(error), + ); + latestConstructedWorker = null; + destroyed.mount(document.createElement("div"), TEST_THEME); + const destroyedWorker = latestWorker(); + destroyed.destroy(); + jest.advanceTimersByTime(30_000); + expect(destroyedFailures).toHaveLength(0); + expect(destroyedWorker.terminations).toBe(1); + } finally { + renderer.destroy(); + jest.useRealTimers(); if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor); else Reflect.deleteProperty(globalThis, "Worker"); if (transferDescriptor) { diff --git a/scripts/public-api-baseline.json b/scripts/public-api-baseline.json index f1e946da..7220914f 100644 --- a/scripts/public-api-baseline.json +++ b/scripts/public-api-baseline.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "manifestFormatVersion": 2, - "sha256": "9cc3fe343edebb80e5eb431b802655092d5ccd02d94ab4dfab76127c312d6192" + "sha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb" }