From 8b9eaeddd721011aa571fb4a508b547e45dfe3b2 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 23 Oct 2026 15:08:05 +0530 Subject: [PATCH 01/71] feat(web): own Rust engine loading and session resources per app --- docs/rust-engine/progress.md | 7 +- .../features/typing/engine-resource.spec.ts | 94 +++++++++++++ frontend/src/features/typing/engine.client.ts | 125 ++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 frontend/__tests__/features/typing/engine-resource.spec.ts create mode 100644 frontend/src/features/typing/engine.client.ts diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index dcbb93e4e..af60d0fe1 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,7 +12,7 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 engine gate passed. Stage 2 website integration next; Stage 3 remains pending. Read root AGENTS.md and the complete migration +Stage 1 engine gate passed. Stage 2 Phase 2.1 active on `stack/rust-engine-website`; Stage 3 remains pending. Read root AGENTS.md and the complete migration plan before edits. Installed workspace dependencies with frozen lockfile. Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. @@ -25,7 +25,8 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 1.5 Generation / configuration | Complete | 61 original snapshots; section/fractional/live generation; all funbox policies; quote refresh RNG; typed recovery and bounded malformed content. | | 1.6 Events / results / replay | Complete | Eight exact original CompletedEvents/hashes/schema/backend outcomes, replay/seek, Unicode history, 1,387 live prefixes and malformed-log validation. | | 1.7 Binding / engine gate | Complete | 24 built-browser cases, unchanged results/hashes, frozen payload/performance limits, fresh production build/typechecks; CI engine/native/Wasm and quality/package/SSR/delivery/update jobs pass at e7b7ad864. Existing website browser shards still running when this record was written. | -| 2.1–2.6 Website | Pending | Requires Stage 1 gate. | +| 2.1 Browser owner / loading | Active | App-owned browser resource; loading/retry/replacement/disposal tests pass. Wiring route loading next. | +| 2.2–2.6 Website integration / cutover | Pending | Requires browser ownership, then typing/generation/results/delivery parity. | | 3.1–3.5 Terminal | Pending | Requires Stage 2 gate. | ## Verification log @@ -240,3 +241,5 @@ do not rerun completed checks without a new reason. Engine gate passed at e7b7ad864 plus automated payload checks ee676ab63. Current API behavior/native/Wasm parity, bounded failures/lifecycles, exact legacy result transport, frozen performance limits and isolated fresh-checkout build/typechecks verified. All original references remain unchanged. Full CI 39932135295 has passed engine, package/frontend/backend, quality, SSR, delivery, assets and update jobs; four existing website browser shards were still running at this handoff. Track their actual final outcome without claiming it early. Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: browser-only app resource, loading/retry/disposal and configuration projection, then replace the typing loop and asset/result/replay consumers. The production website still uses TypeScript until integration passes. No production deployment or release publication performed. Stage 3 remains entirely pending. + +- Stage 2: added browser-only app resource with lazy owned loader, explicit loading/ready/failed/disposed status, retry, session replacement generations and retained content learning. App disposal aborts loading and releases active session/content; stale initialization cannot publish. Three focused ownership/lifecycle tests and type-aware lint pass. Production typing loop still uses TypeScript at this incremental checkpoint. diff --git a/frontend/__tests__/features/typing/engine-resource.spec.ts b/frontend/__tests__/features/typing/engine-resource.spec.ts new file mode 100644 index 000000000..17799c330 --- /dev/null +++ b/frontend/__tests__/features/typing/engine-resource.spec.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +const mocks = vi.hoisted(() => { + const session = { dispose: vi.fn() }; + const content = { + dispose: vi.fn(), + recycleSession: vi.fn(), + createSession: vi.fn(() => session), + }; + const engine = { createContent: vi.fn(() => content) }; + return { + session, + content, + engine, + load: vi.fn(async () => engine), + disposeLoader: vi.fn(), + createLoader: vi.fn(), + }; +}); +vi.mock("@oxytype/typing-engine/browser.client", () => ({ + createEngineLoader: () => { + mocks.createLoader(); + return { load: mocks.load, dispose: mocks.disposeLoader }; + }, +})); +import { mountApplication } from "../../../src/app/application"; +import { getBrowserEngine } from "../../../src/features/typing/engine.client"; +import { + createTestApplication, + releaseTestApplication, +} from "../../__harness__/mock-application"; +releaseTestApplication(); +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + mocks.load.mockReset().mockResolvedValue(mocks.engine); + const app = createTestApplication(); + const release = mountApplication(app.application); + dispose = () => { + release(); + app.dispose(); + }; +}); +afterEach(() => dispose()); + +it("shares loading, retries failures and forbids sessions until ready", async () => { + const resource = getBrowserEngine(); + expect(resource.status().state).toBe("idle"); + expect(() => resource.content()).toThrow("not ready"); + mocks.load.mockRejectedValueOnce(new Error("interrupted download")); + const first = resource.load(); + expect(resource.load()).toBe(first); + await expect(first).rejects.toThrow("interrupted download"); + expect(resource.status().state).toBe("failed"); + await resource.load(); + expect(resource.status().state).toBe("ready"); + expect(mocks.createLoader).toHaveBeenCalledOnce(); + expect(mocks.load).toHaveBeenCalledTimes(2); +}); + +it("recycles a replaced session, invalidates host work and releases all app resources", async () => { + const resource = getBrowserEngine(); + await resource.load(); + const content = resource.beginReplacement(); + const generation = resource.generation(); + const session = resource.createPreparedSession(); + expect(resource.session()).toBe(session); + expect(() => resource.createPreparedSession()).toThrow("prior session"); + expect(resource.beginReplacement()).toBe(content); + expect(resource.generation()).toBeGreaterThan(generation); + expect(mocks.content.recycleSession).toHaveBeenCalledExactlyOnceWith(session); + resource.createPreparedSession(true); + dispose(); + expect(mocks.session.dispose).toHaveBeenCalledOnce(); + expect(mocks.content.dispose).toHaveBeenCalledOnce(); + expect(mocks.disposeLoader).toHaveBeenCalledOnce(); + resource.dispose(); + expect(mocks.content.dispose).toHaveBeenCalledOnce(); + expect(resource.status().state).toBe("disposed"); + expect(() => resource.content()).toThrow("disposed"); + await expect(resource.load()).rejects.toThrow("disposed"); +}); + +it("cannot publish initialization after the app owner was disposed", async () => { + const completion = Promise.withResolvers(); + mocks.load.mockReturnValueOnce(completion.promise); + const resource = getBrowserEngine(); + const loading = resource.load(); + await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledOnce()); + dispose(); + completion.resolve(mocks.engine); + await expect(loading).rejects.toThrow("disposed"); + expect(resource.status().state).toBe("disposed"); + expect(mocks.engine.createContent).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/features/typing/engine.client.ts b/frontend/src/features/typing/engine.client.ts new file mode 100644 index 000000000..12d454594 --- /dev/null +++ b/frontend/src/features/typing/engine.client.ts @@ -0,0 +1,125 @@ +import { createSignal, onCleanup } from "solid-js"; +import { createAppOwned } from "../../app/application"; +import { EngineFailure } from "@oxytype/typing-engine/failure"; +import type { + BrowserContent, + BrowserSession, + Engine, +} from "@oxytype/typing-engine/browser.client"; + +type Loader = ReturnType< + typeof import("@oxytype/typing-engine/browser.client").createEngineLoader +>; +export type EngineStatus = + | { state: "idle" | "loading" | "ready" | "disposed" } + | { state: "failed"; error: unknown }; + +/** One browser engine/content owner per app; every test has a separate session. */ +export const getBrowserEngine = createAppOwned(() => { + const [status, setStatus] = createSignal({ state: "idle" }); + let loader: Loader | undefined; + let engine: Engine | undefined; + let content: BrowserContent | undefined; + let session: BrowserSession | undefined; + let pending: Promise | undefined; + let generation = 0; + let disposed = false; + const assertLive = (): void => { + if (disposed) { + throw new EngineFailure({ + code: "disposed", + message: "Browser engine owner disposed", + recovery: [], + }); + } + }; + // oxlint-disable-next-line typescript/promise-function-async -- Share the exact in-flight initialization promise. + const load = (): Promise => { + if (disposed) { + return Promise.reject( + new EngineFailure({ + code: "disposed", + message: "Browser engine owner disposed", + recovery: [], + }), + ); + } + if (engine !== undefined) return Promise.resolve(engine); + if (pending !== undefined) return pending; + setStatus({ state: "loading" }); + pending = import("@oxytype/typing-engine/browser.client") + .then(async (module) => { + assertLive(); + loader ??= module.createEngineLoader(); + return loader.load(); + }) + .then((loaded) => { + assertLive(); + engine = loaded; + setStatus({ state: "ready" }); + return loaded; + }) + .catch((error: unknown) => { + pending = undefined; + if (!disposed) setStatus({ state: "failed", error }); + throw error; + }); + return pending; + }; + const preparedContent = (): BrowserContent => { + assertLive(); + if (engine === undefined) throw new Error("Typing engine is not ready"); + return (content ??= engine.createContent()); + }; + const releaseSession = (retainLearning = true): void => { + generation++; + const previous = session; + session = undefined; + if (previous === undefined) return; + if (retainLearning && content !== undefined) { + content.recycleSession(previous); + } else { + previous.dispose(); + } + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + releaseSession(false); + content?.dispose(); + content = undefined; + engine = undefined; + loader?.dispose(); + loader = undefined; + pending = undefined; + setStatus({ state: "disposed" }); + }; + onCleanup(dispose); + return { + status, + load, + dispose, + generation: () => generation, + session: () => { + assertLive(); + return session; + }, + /** Recycle before preparing new assets; generation invalidates host continuations. */ + beginReplacement: (): BrowserContent => { + assertLive(); + releaseSession(); + return preparedContent(); + }, + /** Content preparation and any required source replies must finish first. */ + createPreparedSession: (repeating = false): BrowserSession => { + assertLive(); + if (session !== undefined) { + throw new Error("Release the prior session before replacement"); + } + session = preparedContent().createSession(repeating); + return session; + }, + content: preparedContent, + releaseSession, + }; +}); From 7a8db0c3a4e1696dacdef67d9a6911e599d54a4f Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 23 Oct 2026 15:15:35 +0530 Subject: [PATCH 02/71] feat(web): gate typing startup on Wasm readiness and retry failures --- docs/rust-engine/progress.md | 4 +- .../features/typing/lifecycle.spec.ts | 6 +++ .../test/characterization/lifecycle.spec.ts | 4 ++ frontend/__tests__/test/test-logic.spec.ts | 21 +++++++++ frontend/e2e/engine-website.migration.ts | 46 +++++++++++++++++++ .../features/typing/components/TestPage.tsx | 7 +++ frontend/src/features/typing/lifecycle.ts | 2 + .../features/typing/runtime/initialization.ts | 16 +++++++ scripts/engine.ts | 1 + 9 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/engine-website.migration.ts diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index af60d0fe1..db40a3050 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -25,7 +25,7 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 1.5 Generation / configuration | Complete | 61 original snapshots; section/fractional/live generation; all funbox policies; quote refresh RNG; typed recovery and bounded malformed content. | | 1.6 Events / results / replay | Complete | Eight exact original CompletedEvents/hashes/schema/backend outcomes, replay/seek, Unicode history, 1,387 live prefixes and malformed-log validation. | | 1.7 Binding / engine gate | Complete | 24 built-browser cases, unchanged results/hashes, frozen payload/performance limits, fresh production build/typechecks; CI engine/native/Wasm and quality/package/SSR/delivery/update jobs pass at e7b7ad864. Existing website browser shards still running when this record was written. | -| 2.1 Browser owner / loading | Active | App-owned browser resource; loading/retry/replacement/disposal tests pass. Wiring route loading next. | +| 2.1 Browser owner / loading | Complete | App-owned loader/content/session resource; 51 unit cases; actual Cloudflare delayed-download/503/retry browser checks pass; server bundles contain no Wasm initializer. | | 2.2–2.6 Website integration / cutover | Pending | Requires browser ownership, then typing/generation/results/delivery parity. | | 3.1–3.5 Terminal | Pending | Requires Stage 2 gate. | @@ -243,3 +243,5 @@ Engine gate passed at e7b7ad864 plus automated payload checks ee676ab63. Current Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: browser-only app resource, loading/retry/disposal and configuration projection, then replace the typing loop and asset/result/replay consumers. The production website still uses TypeScript until integration passes. No production deployment or release publication performed. Stage 3 remains entirely pending. - Stage 2: added browser-only app resource with lazy owned loader, explicit loading/ready/failed/disposed status, retry, session replacement generations and retained content learning. App disposal aborts loading and releases active session/content; stale initialization cannot publish. Three focused ownership/lifecycle tests and type-aware lint pass. Production typing loop still uses TypeScript at this incremental checkpoint. + +- Phase 2.1 route startup now waits for usable Wasm, disables textarea during loading/restart/failure, uses the existing initialization-error UI and Restart retry, and releases sessions on route exit. Cloudflare production build and 51 lifecycle/unit cases pass; actual website delayed-download and 503/Restart cases pass (2 tests, 6.8 s). Server JS scan finds no engine_bg/wasm_bindgen/WebAssembly/binding initializer. These website cases are included in the shared browser CI task. No typing commands have switched engines yet; Phase 2.2 replaces the loop as a whole. diff --git a/frontend/__tests__/features/typing/lifecycle.spec.ts b/frontend/__tests__/features/typing/lifecycle.spec.ts index cc3155c13..4c19ecedc 100644 --- a/frontend/__tests__/features/typing/lifecycle.spec.ts +++ b/frontend/__tests__/features/typing/lifecycle.spec.ts @@ -8,12 +8,17 @@ const mocks = vi.hoisted(() => { paceReset: vi.fn(), pauseReplay: vi.fn(), sessionReset: vi.fn(), + engineRelease: vi.fn(), funboxClear: vi.fn(async () => undefined), clearGlobal: vi.fn(), layoutfluidHide: vi.fn(), altGrReset: vi.fn(), }; }); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ releaseSession: mocks.engineRelease }), +})); + vi.mock("../../../src/app/readiness", () => ({ preferencesReadiness: () => ({ wait: mocks.preferences }), })); @@ -179,6 +184,7 @@ it("tears down timers, funboxes and incomplete state synchronously on exit", () expect(mocks.paceReset).toHaveBeenCalledOnce(); expect(mocks.pauseReplay).toHaveBeenCalledOnce(); expect(mocks.sessionReset).toHaveBeenCalledOnce(); + expect(mocks.engineRelease).toHaveBeenCalledOnce(); expect(mocks.clearGlobal).toHaveBeenCalledOnce(); expect(mocks.funboxClear).toHaveBeenCalledOnce(); expect(mocks.layoutfluidHide).toHaveBeenCalledOnce(); diff --git a/frontend/__tests__/test/characterization/lifecycle.spec.ts b/frontend/__tests__/test/characterization/lifecycle.spec.ts index dbdfa1866..a9bab377a 100644 --- a/frontend/__tests__/test/characterization/lifecycle.spec.ts +++ b/frontend/__tests__/test/characterization/lifecycle.spec.ts @@ -35,6 +35,10 @@ const mocks = vi.hoisted(() => ({ lowFps: vi.fn(), })); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ load: async () => undefined }), +})); + vi.mock("../../../src/ts/states/test", () => ({ isTestActive: () => mocks.active, setTestActive: (value: boolean) => { diff --git a/frontend/__tests__/test/test-logic.spec.ts b/frontend/__tests__/test/test-logic.spec.ts index 801e8042e..b5fea2078 100644 --- a/frontend/__tests__/test/test-logic.spec.ts +++ b/frontend/__tests__/test/test-logic.spec.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ allGenerated: true, pullSection: undefined as undefined | (() => Promise), sourceActions: [] as unknown[], + engineLoad: vi.fn(async () => undefined), generate: vi.fn(), complete: vi.fn(), sessionStart: vi.fn(), @@ -51,6 +52,10 @@ const mocks = vi.hoisted(() => ({ onFinish: undefined as ((event: { reason?: string }) => void) | undefined, })); +vi.mock("../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ load: mocks.engineLoad }), +})); + vi.mock("../../src/ts/states/test", () => ({ isTestActive: () => mocks.active, setTestActive: (value: boolean) => { @@ -275,6 +280,7 @@ import { let disposeRuntime: () => void; afterEach(() => disposeRuntime()); beforeEach(() => { + mocks.engineLoad.mockReset().mockResolvedValue(undefined); vi.clearAllMocks(); disposeRuntime = createRoot((dispose) => { onCleanup(bindTypingActivity(() => !mocks.transition)); @@ -371,6 +377,21 @@ describe("test lifecycle state", () => { }, ); + it("keeps failed Wasm loading out of the typing loop and retries on restart", async () => { + mocks.engineLoad.mockRejectedValueOnce( + new Error("Typing engine download failed: 503"), + ); + await restart(); + expect(mocks.setInitError).toHaveBeenCalledWith({ + message: "Typing engine download failed: 503", + }); + expect(mocks.generate).not.toHaveBeenCalled(); + expect(mocks.restarting).toBe(false); + await restart(); + expect(mocks.generate).toHaveBeenCalledOnce(); + expect(mocks.uiRestart).toHaveBeenCalledWith("testPage"); + }); + it("caps initialization retries and starts a fresh retry budget on restart", async () => { const error = new Error("generation failed"); mocks.generate.mockRejectedValue(error); diff --git a/frontend/e2e/engine-website.migration.ts b/frontend/e2e/engine-website.migration.ts new file mode 100644 index 000000000..878b4f3c4 --- /dev/null +++ b/frontend/e2e/engine-website.migration.ts @@ -0,0 +1,46 @@ +import type { Route } from "playwright/test"; +import { expect, test } from "./fixtures"; + +test("the website cannot accept typing before its Wasm engine is ready", async ({ + page, +}) => { + const download = Promise.withResolvers(); + await page.route("**/*.wasm", (route) => { + download.resolve(route); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const request = await download.promise; + await expect(page.locator("#wordsInput")).toBeDisabled(); + await expect(page.locator("#words .word")).toHaveCount(0); + await request.continue(); + await expect(page.locator("#wordsInput")).toBeEnabled(); + await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); + const word = page.locator("#words .word").first(); + await word.click(); + const character = (await word.textContent())?.[0]; + if (character === undefined) throw new Error("Missing prepared word"); + await page.keyboard.type(character); + await expect(word.locator("letter.correct")).toHaveCount(1); +}); + +test("a failed engine download shows the existing failure UI and Restart retries", async ({ + page, +}) => { + await page.route("**/*.wasm", async (route) => { + await route.fulfill({ status: 503, body: "engine unavailable" }); + }); + await page.goto("/"); + await expect(page.locator("#testInitFailed")).toBeVisible(); + await expect(page.locator("#testInitFailed")).toContainText( + "Typing engine download failed: 503", + ); + await expect(page.locator("#wordsInput")).toBeDisabled(); + await page.unroute("**/*.wasm"); + await page + .locator("#testInitFailed") + .getByRole("button", { name: "Restart", exact: true }) + .click(); + await expect(page.locator("#testInitFailed")).toBeHidden(); + await expect(page.locator("#wordsInput")).toBeEnabled(); + await expect(page.locator("#words .word").first()).toBeVisible(); +}); diff --git a/frontend/src/features/typing/components/TestPage.tsx b/frontend/src/features/typing/components/TestPage.tsx index 676c64f7c..e12a13809 100644 --- a/frontend/src/features/typing/components/TestPage.tsx +++ b/frontend/src/features/typing/components/TestPage.tsx @@ -4,6 +4,7 @@ import { spread } from "solid-js/web"; import { useWordsInputScroll } from "../../../ts/hooks/useWordsInputScroll"; import { isInputElementFocused } from "../../../ts/input/input-element"; import { isWordsWrapperVisible } from "../../../ts/states/funbox"; +import { isTestRestarting, getTestInitError } from "../../../ts/states/test"; import { setTestElements } from "../../../ts/states/test-dom"; import { getWordsWrapperHeight, @@ -11,6 +12,7 @@ import { } from "../../../ts/states/words-layout"; import { cn } from "../../../ts/utils/cn"; import { isCoarsePointer } from "../../../ts/utils/pointer"; +import { getBrowserEngine } from "../engine.client"; import { focusWords } from "../runtime/test-ui"; import { CapsWarning } from "./CapsWarning"; import { CompositionDisplay } from "./CompositionDisplay"; @@ -112,6 +114,11 @@ export function TestPage(