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
5 changes: 5 additions & 0 deletions .changeset/brave-workers-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@ Source entry: `packages/core/src/worker.ts`
### Types <span class="api-count" data-pagefind-ignore>1</span>

<div class="api-symbol-grid">
<a class="api-symbol-card" href="/docs/api/core-worker/worker-acknowledgement/"><span class="api-symbol-card__head"><span class="api-symbol-badge" data-kind="type" aria-hidden="true">T</span><code>WorkerAcknowledgement</code></span><span class="api-symbol-card__desc">Acknowledgement posted back to the sender after a frame actually painted.</span></a>
<a class="api-symbol-card" href="/docs/api/core-worker/worker-acknowledgement/"><span class="api-symbol-card__head"><span class="api-symbol-badge" data-kind="type" aria-hidden="true">T</span><code>WorkerAcknowledgement</code></span><span class="api-symbol-card__desc">Lifecycle and frame acknowledgements posted back to the sender.</span></a>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ without booting a browser Worker.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Package</dt><dd><code>@sheetwrite/core/worker</code></dd></div>
<div><dt>Source</dt><dd><code>packages/core/src/worker.ts#L256</code></dd></div>
<div><dt>Source</dt><dd><code>packages/core/src/worker.ts#L259</code></dd></div>
</dl>

## Declaration
Expand Down
42 changes: 31 additions & 11 deletions docs/src/content/docs/api/core-worker/worker-acknowledgement.md
Original file line number Diff line number Diff line change
@@ -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."
---
<!-- api-export:@sheetwrite/core|./worker|WorkerAcknowledgement -->
<div class="api-pagehead"><a class="api-backlink" href="/docs/api/core-worker/">@sheetwrite/core/worker</a><span class="api-status" data-kind="type">type</span></div>

Acknowledgement posted back to the sender after a frame actually painted.
Lifecycle and frame acknowledgements posted back to the sender.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Package</dt><dd><code>@sheetwrite/core/worker</code></dd></div>
<div><dt>Source</dt><dd><code>packages/core/src/worker.ts#L249</code></dd></div>
</dl>

## Members <span class="api-count" data-pagefind-ignore>1</span>
## Variants <span class="api-count" data-pagefind-ignore>3</span>

<div class="api-member-list">
<div class="api-variant-list" data-pagefind-ignore>
<div class="api-variant">

<details class="api-member" id="worker-acknowledgement-type" data-pagefind-weight="1">
<summary><code>type</code></summary>
```ts generated
{ type: "ready" }
```

</div>
<div class="api-variant">

```ts generated
type: "painted"
{ type: "fatal"; reason: string }
```

</details>
</div>
<div class="api-variant">

```ts generated
{ type: "painted" }
```

</div>
</div>

## Declaration
Expand All @@ -32,9 +44,17 @@ type: "painted"
<summary>View full TypeScript declaration</summary>

```ts generated
export type WorkerAcknowledgement = {
type: "painted";
};
export type WorkerAcknowledgement =
| {
type: "ready";
}
| {
type: "fatal";
reason: string;
}
| {
type: "painted";
};
```

</details>
2 changes: 1 addition & 1 deletion docs/src/generated/docs-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"entryPointCount": 18,
"exportCount": 468,
"symbolPageCount": 468,
"apiSha256": "9cc3fe343edebb80e5eb431b802655092d5ccd02d94ab4dfab76127c312d6192",
"apiSha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb",
"compatibilitySha256": "3dc63d06901349f4edd5b2b5bc332b87cdbecacc54d303c85eb2b0b1354ed01a",
"formulaContractSha256": "c19bbb023fd08bd378ca2d0b82761fa1b203e29f93a5d4b32bf4a2e6ce4affaf"
}
6 changes: 3 additions & 3 deletions docs/src/generated/public-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -6610,21 +6610,21 @@
"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": []
},
{
"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": []
}
]
Expand Down
51 changes: 37 additions & 14 deletions packages/core/src/worker-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +117,7 @@ export class WorkerRenderer implements Renderer {
private readonly sharedRegions: Array<SharedRegion | undefined> = new Array(SHARED_REGION_COUNT);
private failed = false;
private frameGeneration = 0;
private readinessTimeout: number | undefined;

constructor(
private readonly workerUrl?: string | URL,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<unknown>): 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" &&
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading
Loading