diff --git a/.changeset/decode-worker-heartbreak.md b/.changeset/decode-worker-heartbreak.md new file mode 100644 index 0000000000..03bc0b603d --- /dev/null +++ b/.changeset/decode-worker-heartbreak.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/web-core": patch +--- + +Add bidirectional decode worker heartbreak liveness messages. diff --git a/packages/web-platform/web-core/tests/template-manager.spec.ts b/packages/web-platform/web-core/tests/template-manager.spec.ts index a45f35314b..37d04c37de 100644 --- a/packages/web-platform/web-core/tests/template-manager.spec.ts +++ b/packages/web-platform/web-core/tests/template-manager.spec.ts @@ -3,6 +3,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { encode, type TasmJSONInfo } from '../ts/encode/index.js'; import { MagicHeader0, MagicHeader1 } from '../ts/constants.js'; import type { LynxViewInstance } from '../ts/client/mainthread/LynxViewInstance.js'; +import type { HeartbreakMessage } from '../ts/client/decodeWorker/types.js'; // Import the worker script to execute it and register the handler await import('../ts/client/decodeWorker/decode.worker.js'); @@ -49,12 +50,43 @@ const mockLynxViewInstance = { }), } as unknown as LynxViewInstance; +function isHeartbreakMessage(message: unknown): message is HeartbreakMessage { + return typeof message === 'object' + && message !== null + && (message as Partial).type === 'heartbreak'; +} + describe('Template Manager', () => { beforeEach(() => { vi.clearAllMocks(); globalThis.fetch = vi.fn(); }); + test('should exchange worker-level heartbreak ack messages', async () => { + const postMessageSpy = vi.spyOn(globalThis, 'postMessage'); + + try { + const startedAt = performance.now(); + let heartbreakMessages = postMessageSpy.mock.calls.filter( + ([message]) => isHeartbreakMessage(message), + ); + + while ( + heartbreakMessages.length < 2 + && performance.now() - startedAt < 5000 + ) { + await new Promise(resolve => setTimeout(resolve, 50)); + heartbreakMessages = postMessageSpy.mock.calls.filter( + ([message]) => isHeartbreakMessage(message), + ); + } + + expect(heartbreakMessages.length).toBeGreaterThanOrEqual(2); + } finally { + postMessageSpy.mockRestore(); + } + }); + test('should encode and decode correctly with version 1', async () => { const templateUrl = 'http://example.com/template_version_test'; const encoded = encode(sampleTasm); diff --git a/packages/web-platform/web-core/ts/client/decodeWorker/decode.worker.ts b/packages/web-platform/web-core/ts/client/decodeWorker/decode.worker.ts index 6532376b4b..d77c910bb7 100644 --- a/packages/web-platform/web-core/ts/client/decodeWorker/decode.worker.ts +++ b/packages/web-platform/web-core/ts/client/decodeWorker/decode.worker.ts @@ -3,7 +3,12 @@ import { MagicHeader0, MagicHeader1, } from '../../constants.js'; -import type { InitMessage, LoadTemplateMessage, MainMessage } from './types.js'; +import type { + HeartbreakMessage, + InitMessage, + LoadTemplateMessage, + MainMessage, +} from './types.js'; import { wasmInstance } from '../wasm.js'; import type { PageConfig } from '../../types/PageConfig.js'; @@ -16,6 +21,9 @@ const wasmModuleLoadedPromise: Promise = new Promise((resolve) => { import { loadStyleFromJSON } from './cssLoader.js'; import { decodeBinaryMap } from '../../common/decodeUtils.js'; +const HEARTBREAK_INTERVAL_MS = 1000; +let heartbreakTimer: ReturnType | undefined; + class StreamReader { #reader: ReadableStreamDefaultReader; #buffer: Uint8Array = new Uint8Array(0); @@ -97,14 +105,40 @@ function decodeJSONMap(buffer: Uint8Array): Record { return JSON.parse(jsonString); } +function postHeartbreak() { + postMessage({ type: 'heartbreak' } as MainMessage); +} + +function unrefTimer(timer: ReturnType) { + if (typeof timer === 'object' && timer !== null && 'unref' in timer) { + (timer as { unref: () => void }).unref(); + } +} + +function scheduleHeartbreak() { + if (heartbreakTimer !== undefined) { + return; + } + heartbreakTimer = setTimeout(() => { + heartbreakTimer = undefined; + postHeartbreak(); + }, HEARTBREAK_INTERVAL_MS); + unrefTimer(heartbreakTimer); +} + self.onmessage = async ( - event: MessageEvent | MessageEvent, + event: + | MessageEvent + | MessageEvent + | MessageEvent, ) => { const data = event.data; if (data.type === 'init') { const { wasmModule } = data; wasmInstance.initSync({ module: wasmModule }); wasmModuleLoadedResolve(); + } else if (data.type === 'heartbreak') { + scheduleHeartbreak(); } else if (data.type === 'load') { const { url, @@ -462,3 +496,4 @@ async function handleJSON( } postMessage({ type: 'ready' } as MainMessage); +scheduleHeartbreak(); diff --git a/packages/web-platform/web-core/ts/client/decodeWorker/types.ts b/packages/web-platform/web-core/ts/client/decodeWorker/types.ts index 3cdef345a6..a5272045e0 100644 --- a/packages/web-platform/web-core/ts/client/decodeWorker/types.ts +++ b/packages/web-platform/web-core/ts/client/decodeWorker/types.ts @@ -35,13 +35,18 @@ export interface DoneMessage extends DecodeWorkerMessage { type: 'done'; } +export interface HeartbreakMessage { + type: 'heartbreak'; +} + export interface ReadyMessage { type: 'ready'; } -export type WorkerMessage = LoadTemplateMessage; +export type WorkerMessage = LoadTemplateMessage | HeartbreakMessage; export type MainMessage = | SectionMessage | ErrorMessage | DoneMessage + | HeartbreakMessage | ReadyMessage; diff --git a/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts b/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts index 2ac62db93b..42fca1fbb8 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts @@ -181,6 +181,10 @@ export class TemplateManager { } return; } + if (msg.type === 'heartbreak') { + this.#worker?.postMessage({ type: 'heartbreak' }); + return; + } const { url } = msg; const lynxViewInstancePromise = this.#lynxViewInstancesMap.get(url); if (!lynxViewInstancePromise) return;