diff --git a/README.md b/README.md index a7d03d3..3bd182f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Reusable building blocks for iterative solvers, multi-stage solver pipelines, an - `BaseSolver`: a safe base class for step-based solvers. - `BasePipelineSolver`: orchestration for sequential solver stages. +- `SolverWorkerClient` + `exposeSolverWorker`: a standard way to run a solver inside a Web Worker. - React debugger components for stepping, animating, and inspecting solver state. ## Table of Contents @@ -15,6 +16,7 @@ Reusable building blocks for iterative solvers, multi-stage solver pipelines, an - [Core Concepts](#core-concepts) - [BaseSolver Guide](#basesolver-guide) - [BasePipelineSolver Guide](#basepipelinesolver-guide) +- [Solver Worker Guide](#solver-worker-guide) - [React Debugger Guide](#react-debugger-guide) - [Testing Solvers](#testing-solvers) - [API Reference](#api-reference) @@ -176,7 +178,37 @@ console.log(pipeline.getAllOutputs()) console.log(pipeline.getStageStats()) ``` -### 3) Debug in React +### 3) Run a solver in a Web Worker + +`SolverWorkerClient` is the async mirror of `BaseSolver` for worker-backed solvers. + +```ts +// CountToTenSolver.worker.ts +import { exposeSolverWorker } from "@tscircuit/solver-utils" +import { CountToTenSolver } from "./CountToTenSolver" + +exposeSolverWorker(CountToTenSolver) +``` + +```ts +// main-thread.ts +import { SolverWorkerClient } from "@tscircuit/solver-utils" + +const worker = new Worker( + new URL("./CountToTenSolver.worker.ts", import.meta.url), + { type: "module" }, +) + +const solver = await SolverWorkerClient.create(worker) +await solver.solve() + +console.log({ + solved: solver.solved, + snapshot: solver.snapshot, +}) +``` + +### 4) Debug in React ```tsx import { useMemo } from "react" @@ -204,6 +236,7 @@ export default function SolverPage() { - `visualize()`: return a `GraphicsObject` for rendering. - `getConstructorParams()`: return reproducible constructor input (used by download helpers). - `getOutput()`: standardized solved result (especially useful in pipelines). +- `getSerializableSnapshot()`: standardized structured-clone-safe state for worker transport. ## BaseSolver Guide @@ -267,6 +300,7 @@ class MySolver extends BaseSolver { - Exceptions thrown in `_step()` are captured, `failed` is set, and the error is re-thrown. - On max-iteration exhaustion, `tryFinalAcceptance()` is called before failing. - You can override `tryFinalAcceptance()` for “best effort” acceptance logic. +- Override `getSerializableState()` if the default public-field snapshot is too large or contains values that should not cross a worker boundary. ## BasePipelineSolver Guide @@ -315,6 +349,103 @@ You can override: These are inserted as extra visualization steps around stage visuals. +## Solver Worker Guide + +The worker convention is: + +1. A dedicated worker owns exactly one solver instance. +2. The worker entry calls `exposeSolverWorker(...)`. +3. The main thread talks to that worker through `SolverWorkerClient`. +4. State crosses the worker boundary as a `getSerializableSnapshot()` payload, not as a live class instance. + +`SolverWorkerClient` intentionally does **not** extend `BaseSolver`. The local object is a proxy over message passing, so the common lifecycle methods become async: + +- `init(...args)` +- `step()` +- `solve({ snapshotEvery? })` +- `syncSnapshot()` +- `getOutput()` +- `getConstructorParams()` +- `visualize()` +- `preview()` +- `call(methodName, ...args)` +- `dispose()` + +### Canonical worker packaging + +Put a dedicated entrypoint next to the solver and keep it very small: + +```ts +// MySolver.worker.ts +import { exposeSolverWorker } from "@tscircuit/solver-utils" +import { MySolver } from "./MySolver" + +exposeSolverWorker(MySolver) +``` + +On the main thread: + +```ts +import { SolverWorkerClient } from "@tscircuit/solver-utils" + +const worker = new Worker(new URL("./MySolver.worker.ts", import.meta.url), { + type: "module", +}) + +const solver = await SolverWorkerClient.create(worker, { + input: "whatever your solver constructor expects", +}) + +await solver.solve() +const output = await solver.getOutput() +``` + +If the worker needs custom construction logic, use the object form: + +```ts +import { exposeSolverWorker } from "@tscircuit/solver-utils" +import { MySolver } from "./MySolver" + +exposeSolverWorker({ + createSolver: (input, options) => new MySolver({ input, seed: options.seed }), +}) +``` + +### Snapshot contract + +`BaseSolver#getSerializableSnapshot()` is the common wire format used by the worker helpers. It includes: + +- base solver metadata (`solved`, `failed`, `iterations`, `progress`, `error`, `timeToSolve`) +- `stats` +- `activeSubSolver` +- `failedSubSolvers` +- `state`, which defaults to all enumerable public fields except the base runtime fields + +This means most existing solvers can cross the worker boundary without any extra work. If a solver has very large state, circular references you want to trim, or non-cloneable values, override `getSerializableState()`. + +### Progress streaming + +`await solver.solve({ snapshotEvery: 100 })` makes the worker emit snapshots every 100 iterations. Subscribe on the client when you want coarse-grained live updates without stepping manually: + +```ts +const unsubscribe = solver.subscribe((snapshot) => { + console.log(snapshot.iterations, snapshot.progress) +}) + +await solver.solve({ snapshotEvery: 100 }) +unsubscribe() +``` + +### Custom solver methods + +The worker convention standardizes the common `BaseSolver` surface, but solvers often have domain-specific helpers like `getFinalPosition()`. Use `call(...)` for that: + +```ts +const finalPosition = await solver.call("getFinalPosition") +``` + +Prefer `getOutput()` for stable result contracts, and keep `call(...)` for explicit solver-specific escape hatches. + ## React Debugger Guide Import from: @@ -380,6 +511,8 @@ bun test - `BaseSolver` - `BasePipelineSolver` - `definePipelineStep` + - `SolverWorkerClient` + - `exposeSolverWorker` - React: `@tscircuit/solver-utils/react` - `GenericSolverDebugger` - `GenericSolverToolbar` @@ -397,6 +530,11 @@ bun test - `stats = {}` - `MAX_ITERATIONS = 100_000` +### `BaseSolver` worker snapshot helpers + +- `getSerializableState()` +- `getSerializableSnapshot()` + ### `BasePipelineSolver` notable state - `currentPipelineStageIndex` @@ -450,4 +588,5 @@ bun run format - If input download fails, ensure `getConstructorParams()` is implemented. - If solver never terminates, confirm `_step()` eventually sets `solved` or `failed`. - If pipeline stage lookup fails, confirm `solverName` matches property access in `getSolver("name")`. +- If worker snapshots fail to cross the thread boundary, override `getSerializableState()` to remove non-cloneable values. - If React debugger renders nothing, make sure `visualize()` returns at least one non-empty primitive array. diff --git a/lib/BaseSolver.ts b/lib/BaseSolver.ts index e071bf8..1280ca2 100644 --- a/lib/BaseSolver.ts +++ b/lib/BaseSolver.ts @@ -1,4 +1,34 @@ import type { GraphicsObject } from "graphics-debug" +import { toStructuredCloneSafe } from "./structured-clone" + +export interface BaseSolverSnapshot { + solverName: string + solved: boolean + failed: boolean + iterations: number + progress: number + error: string | null + maxIterations: number + timeToSolve?: number + stats: Record + state: Record + activeSubSolver: BaseSolverSnapshot | null + failedSubSolvers: BaseSolverSnapshot[] +} + +const BASE_SOLVER_SNAPSHOT_KEYS = new Set([ + "MAX_ITERATIONS", + "solved", + "failed", + "iterations", + "progress", + "error", + "activeSubSolver", + "failedSubSolvers", + "timeToSolve", + "stats", + "_setupDone", +]) /** * A base class for solvers implementing the standard solve() interface. @@ -118,4 +148,55 @@ export class BaseSolver { circles: [], } } + + /** + * Override this when the default "all enumerable public fields" state + * snapshot is too large or includes non-cloneable values. + */ + getSerializableState(): Record { + const state: Record = {} + + for (const [key, value] of Object.entries(this)) { + if (key.startsWith("_") || BASE_SOLVER_SNAPSHOT_KEYS.has(key)) { + continue + } + + if (value instanceof BaseSolver) { + state[key] = value.getSerializableSnapshot() + continue + } + + if ( + Array.isArray(value) && + value.every((entry) => entry instanceof BaseSolver) + ) { + state[key] = value.map((entry) => entry.getSerializableSnapshot()) + continue + } + + state[key] = toStructuredCloneSafe(value) + } + + return state + } + + getSerializableSnapshot(): BaseSolverSnapshot { + return { + solverName: this.getSolverName(), + solved: this.solved, + failed: this.failed, + iterations: this.iterations, + progress: this.progress, + error: this.error, + maxIterations: this.MAX_ITERATIONS, + timeToSolve: this.timeToSolve, + stats: toStructuredCloneSafe(this.stats), + state: this.getSerializableState(), + activeSubSolver: this.activeSubSolver?.getSerializableSnapshot() ?? null, + failedSubSolvers: + this.failedSubSolvers?.map((solver) => + solver.getSerializableSnapshot(), + ) ?? [], + } + } } diff --git a/lib/SolverWorker.ts b/lib/SolverWorker.ts new file mode 100644 index 0000000..58d98e6 --- /dev/null +++ b/lib/SolverWorker.ts @@ -0,0 +1,644 @@ +import type { GraphicsObject } from "graphics-debug" +import { BaseSolver, type BaseSolverSnapshot } from "./BaseSolver" +import { getErrorMessage, toStructuredCloneSafe } from "./structured-clone" + +export const SOLVER_WORKER_CHANNEL = "solver-worker" + +export interface SolverWorkerEndpoint { + postMessage(message: unknown, transfer?: Transferable[]): void + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void + removeEventListener?( + type: "message", + listener: (event: MessageEvent) => void, + ): void + start?(): void +} + +export interface SolverWorkerClientEndpoint extends SolverWorkerEndpoint { + terminate?(): void +} + +export interface SolveInWorkerOptions { + snapshotEvery?: number +} + +export type SolverWorkerRequestPayload = + | { + action: "init" + args: unknown[] + } + | { + action: "step" + } + | { + action: "solve" + snapshotEvery?: number + } + | { + action: "getSnapshot" + } + | { + action: "getOutput" + } + | { + action: "getConstructorParams" + } + | { + action: "visualize" + } + | { + action: "preview" + } + | { + action: "call" + methodName: string + args: unknown[] + } + | { + action: "dispose" + } + +export type SolverWorkerRequest = SolverWorkerRequestPayload & { + channel: typeof SOLVER_WORKER_CHANNEL + kind: "request" + requestId: number +} + +export interface SolverWorkerSnapshotEvent { + channel: typeof SOLVER_WORKER_CHANNEL + kind: "event" + event: "snapshot" + snapshot: BaseSolverSnapshot +} + +export interface SolverWorkerSuccessResponse { + channel: typeof SOLVER_WORKER_CHANNEL + kind: "response" + requestId: number + ok: true + result: TResult + snapshot: BaseSolverSnapshot | null +} + +export interface SolverWorkerErrorResponse { + channel: typeof SOLVER_WORKER_CHANNEL + kind: "response" + requestId: number + ok: false + error: string + snapshot: BaseSolverSnapshot | null +} + +export type SolverWorkerResponse = + | SolverWorkerSuccessResponse + | SolverWorkerErrorResponse + +export type SolverWorkerMessage = + | SolverWorkerRequest + | SolverWorkerResponse + | SolverWorkerSnapshotEvent + +type SolverConstructor = new ( + ...args: any[] +) => TSolver + +type SolverFactory = (...args: any[]) => TSolver + +export type SolverWorkerDefinition = + | SolverConstructor + | { + solverClass?: SolverConstructor + createSolver?: SolverFactory + target?: SolverWorkerEndpoint + } + +export interface AsyncSolverLike< + TSnapshot extends BaseSolverSnapshot = BaseSolverSnapshot, +> { + readonly snapshot: TSnapshot | null + readonly solverName: string | null + readonly solved: boolean + readonly failed: boolean + readonly iterations: number + readonly progress: number + readonly error: string | null + readonly stats: Record + readonly state: Record + readonly activeSubSolver: BaseSolverSnapshot | null + init(...args: unknown[]): Promise + step(): Promise + solve(options?: SolveInWorkerOptions): Promise + syncSnapshot(): Promise + getOutput(): Promise + getConstructorParams(): Promise + visualize(): Promise + preview(): Promise + call( + methodName: string, + ...args: unknown[] + ): Promise + subscribe(listener: (snapshot: TSnapshot) => void): () => void + dispose(): Promise + terminate(): void +} + +function isSolverWorkerMessage( + message: unknown, +): message is SolverWorkerMessage { + if (!message || typeof message !== "object") { + return false + } + + const candidate = message as Partial + return candidate.channel === SOLVER_WORKER_CHANNEL +} + +function getSnapshotEvery(snapshotEvery?: number): number | null { + if (!snapshotEvery || !Number.isFinite(snapshotEvery)) { + return null + } + + return Math.max(1, Math.floor(snapshotEvery)) +} + +function serializeWorkerResult(value: unknown): unknown { + if (value instanceof BaseSolver) { + return value.getSerializableSnapshot() + } + + if ( + Array.isArray(value) && + value.every((entry) => entry instanceof BaseSolver) + ) { + return value.map((entry) => entry.getSerializableSnapshot()) + } + + return toStructuredCloneSafe(value) +} + +function getDefaultWorkerEndpoint(): SolverWorkerEndpoint { + const defaultTarget = globalThis as Partial + if ( + typeof defaultTarget.postMessage === "function" && + typeof defaultTarget.addEventListener === "function" + ) { + return defaultTarget as SolverWorkerEndpoint + } + + throw new Error( + "No worker endpoint was found. Pass target explicitly when registering the solver worker.", + ) +} + +function resolveCreateSolver( + definition: SolverWorkerDefinition, +): { + createSolver: SolverFactory + target?: SolverWorkerEndpoint +} { + if (typeof definition === "function") { + return { + createSolver: (...args) => new definition(...args), + } + } + + if (definition.createSolver) { + return { + createSolver: definition.createSolver, + target: definition.target, + } + } + + if (definition.solverClass) { + return { + createSolver: (...args) => new definition.solverClass!(...args), + target: definition.target, + } + } + + throw new Error( + "Solver worker registration requires either solverClass or createSolver.", + ) +} + +function requireSolver( + solver: TSolver | null, +): TSolver { + if (!solver) { + throw new Error("Solver has not been initialized. Call init(...) first.") + } + + return solver +} + +function solveSolver( + solver: BaseSolver, + snapshotEvery: number | null, + emitSnapshot: (snapshot: BaseSolverSnapshot) => void, +): BaseSolverSnapshot { + const startTime = Date.now() + let lastEmittedIteration = -1 + + while (!solver.solved && !solver.failed) { + solver.step() + + if ( + snapshotEvery && + solver.iterations % snapshotEvery === 0 && + solver.iterations !== lastEmittedIteration + ) { + emitSnapshot(solver.getSerializableSnapshot()) + lastEmittedIteration = solver.iterations + } + } + + solver.timeToSolve = Date.now() - startTime + + const finalSnapshot = solver.getSerializableSnapshot() + if (snapshotEvery && solver.iterations !== lastEmittedIteration) { + emitSnapshot(finalSnapshot) + } + + return finalSnapshot +} + +export function exposeSolverWorker( + definition: SolverWorkerDefinition, +): () => void { + const { createSolver, target: explicitTarget } = + resolveCreateSolver(definition) + const target = explicitTarget ?? getDefaultWorkerEndpoint() + let solver: TSolver | null = null + + const postMessage = (message: SolverWorkerMessage) => { + target.postMessage(toStructuredCloneSafe(message)) + } + + const emitSnapshot = (snapshot: BaseSolverSnapshot) => { + postMessage({ + channel: SOLVER_WORKER_CHANNEL, + kind: "event", + event: "snapshot", + snapshot, + }) + } + + const handleMessage = async (event: MessageEvent) => { + const message = event.data + if (!isSolverWorkerMessage(message) || message.kind !== "request") { + return + } + + try { + let result: unknown = null + + switch (message.action) { + case "init": { + solver = createSolver(...message.args) + result = solver.getSerializableSnapshot() + break + } + case "step": { + const activeSolver = requireSolver(solver) + activeSolver.step() + result = activeSolver.getSerializableSnapshot() + break + } + case "solve": { + const activeSolver = requireSolver(solver) + result = solveSolver( + activeSolver, + getSnapshotEvery(message.snapshotEvery), + emitSnapshot, + ) + break + } + case "getSnapshot": { + result = requireSolver(solver).getSerializableSnapshot() + break + } + case "getOutput": { + result = requireSolver(solver).getOutput() + break + } + case "getConstructorParams": { + result = requireSolver(solver).getConstructorParams() + break + } + case "visualize": { + result = requireSolver(solver).visualize() + break + } + case "preview": { + result = requireSolver(solver).preview() + break + } + case "call": { + const activeSolver = requireSolver(solver) + const candidate = (activeSolver as any)[message.methodName] + + if (typeof candidate !== "function") { + throw new Error( + `Solver method "${message.methodName}" does not exist or is not callable.`, + ) + } + + result = await candidate.apply(activeSolver, message.args) + break + } + case "dispose": { + solver = null + result = null + break + } + default: { + const exhaustiveCheck: never = message + throw new Error( + `Unsupported worker action: ${String(exhaustiveCheck)}`, + ) + } + } + + postMessage({ + channel: SOLVER_WORKER_CHANNEL, + kind: "response", + requestId: message.requestId, + ok: true, + result: serializeWorkerResult(result), + snapshot: solver?.getSerializableSnapshot() ?? null, + }) + } catch (error) { + postMessage({ + channel: SOLVER_WORKER_CHANNEL, + kind: "response", + requestId: message.requestId, + ok: false, + error: getErrorMessage(error), + snapshot: solver?.getSerializableSnapshot() ?? null, + }) + } + } + + target.addEventListener("message", handleMessage) + target.start?.() + + return () => { + target.removeEventListener?.("message", handleMessage) + } +} + +type PendingRequest = { + resolve: (response: SolverWorkerSuccessResponse) => void + reject: (error: Error) => void +} + +export class SolverWorkerClient< + TSnapshot extends BaseSolverSnapshot = BaseSolverSnapshot, +> implements AsyncSolverLike +{ + private nextRequestId = 1 + private pendingRequests = new Map>() + private snapshotListeners = new Set<(snapshot: TSnapshot) => void>() + private currentSnapshot: TSnapshot | null = null + + constructor(private readonly target: SolverWorkerClientEndpoint) { + this.target.addEventListener("message", this.handleMessage) + this.target.start?.() + } + + static async create< + TSnapshot extends BaseSolverSnapshot = BaseSolverSnapshot, + >( + target: SolverWorkerClientEndpoint, + ...args: unknown[] + ): Promise> { + const client = new SolverWorkerClient(target) + await client.init(...args) + return client + } + + get snapshot(): TSnapshot | null { + return this.currentSnapshot + } + + get solverName(): string | null { + return this.currentSnapshot?.solverName ?? null + } + + get solved(): boolean { + return this.currentSnapshot?.solved ?? false + } + + get failed(): boolean { + return this.currentSnapshot?.failed ?? false + } + + get iterations(): number { + return this.currentSnapshot?.iterations ?? 0 + } + + get progress(): number { + return this.currentSnapshot?.progress ?? 0 + } + + get error(): string | null { + return this.currentSnapshot?.error ?? null + } + + get stats(): Record { + return this.currentSnapshot?.stats ?? {} + } + + get state(): Record { + return this.currentSnapshot?.state ?? {} + } + + get activeSubSolver(): BaseSolverSnapshot | null { + return this.currentSnapshot?.activeSubSolver ?? null + } + + async init(...args: unknown[]): Promise { + return this.requestSnapshot({ + action: "init", + args, + }) + } + + async step(): Promise { + return this.requestSnapshot({ + action: "step", + }) + } + + async solve(options: SolveInWorkerOptions = {}): Promise { + return this.requestSnapshot({ + action: "solve", + snapshotEvery: options.snapshotEvery, + }) + } + + async syncSnapshot(): Promise { + return this.requestSnapshot({ + action: "getSnapshot", + }) + } + + async getOutput(): Promise { + const response = await this.request({ + action: "getOutput", + }) + return response.result + } + + async getConstructorParams< + TArgs extends unknown[] = unknown[], + >(): Promise { + const response = await this.request({ + action: "getConstructorParams", + }) + return response.result + } + + async visualize(): Promise { + const response = await this.request({ + action: "visualize", + }) + return response.result + } + + async preview(): Promise { + const response = await this.request({ + action: "preview", + }) + return response.result + } + + async call( + methodName: string, + ...args: unknown[] + ): Promise { + const response = await this.request({ + action: "call", + methodName, + args, + }) + return response.result + } + + subscribe(listener: (snapshot: TSnapshot) => void): () => void { + this.snapshotListeners.add(listener) + + if (this.currentSnapshot) { + listener(this.currentSnapshot) + } + + return () => { + this.snapshotListeners.delete(listener) + } + } + + async dispose(): Promise { + await this.request({ + action: "dispose", + }) + } + + terminate(): void { + this.teardown() + this.target.terminate?.() + } + + private handleMessage = (event: MessageEvent) => { + const message = event.data + if (!isSolverWorkerMessage(message)) { + return + } + + if (message.kind === "request") { + return + } + + if (message.kind === "event") { + this.setSnapshot(message.snapshot as TSnapshot) + return + } + + this.setSnapshot(message.snapshot as TSnapshot | null) + + const pending = this.pendingRequests.get(message.requestId) + if (!pending) { + return + } + + this.pendingRequests.delete(message.requestId) + + if (message.ok) { + pending.resolve(message) + return + } + + pending.reject(new Error(message.error)) + } + + private setSnapshot(snapshot: TSnapshot | null) { + this.currentSnapshot = snapshot + + if (!snapshot) { + return + } + + for (const listener of this.snapshotListeners) { + listener(snapshot) + } + } + + private async request( + message: SolverWorkerRequestPayload, + ): Promise> { + const requestId = this.nextRequestId++ + const request: SolverWorkerRequest = { + channel: SOLVER_WORKER_CHANNEL, + kind: "request", + requestId, + ...message, + } + + const responsePromise = new Promise>( + (resolve, reject) => { + this.pendingRequests.set(requestId, { + resolve: resolve as PendingRequest["resolve"], + reject, + }) + }, + ) + + this.target.postMessage(toStructuredCloneSafe(request)) + return responsePromise + } + + private async requestSnapshot( + message: SolverWorkerRequestPayload, + ): Promise { + const response = await this.request(message) + if (!response.snapshot) { + throw new Error("Solver worker did not return a snapshot.") + } + + return response.snapshot as TSnapshot + } + + private teardown() { + this.target.removeEventListener?.("message", this.handleMessage) + + for (const [requestId, pending] of this.pendingRequests) { + this.pendingRequests.delete(requestId) + pending.reject(new Error("Solver worker client was terminated.")) + } + + this.snapshotListeners.clear() + this.currentSnapshot = null + } +} diff --git a/lib/index.ts b/lib/index.ts index 1e42595..22c74fe 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,2 +1,3 @@ export * from "./BaseSolver" export * from "./BasePipelineSolver" +export * from "./SolverWorker" diff --git a/lib/structured-clone.ts b/lib/structured-clone.ts new file mode 100644 index 0000000..bf82b3c --- /dev/null +++ b/lib/structured-clone.ts @@ -0,0 +1,112 @@ +function manuallySanitizeValue(value: unknown, seen: WeakSet): unknown { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" || + typeof value === "undefined" + ) { + return value + } + + if (typeof value === "function" || typeof value === "symbol") { + return undefined + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + } + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (value instanceof URL) { + return value.toString() + } + + if (value instanceof RegExp) { + return value.toString() + } + + if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) { + return value.slice(0) + } + + if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value)) { + if ("length" in value) { + return Array.from(value as unknown as ArrayLike) + } + + return { + byteLength: value.byteLength, + } + } + + if (value instanceof Map) { + return Array.from(value.entries(), ([key, mapValue]) => [ + manuallySanitizeValue(key, seen), + manuallySanitizeValue(mapValue, seen), + ]) + } + + if (value instanceof Set) { + return Array.from(value.values(), (setValue) => + manuallySanitizeValue(setValue, seen), + ) + } + + if (typeof value !== "object") { + return String(value) + } + + if (seen.has(value)) { + return "[Circular]" + } + + seen.add(value) + + if (Array.isArray(value)) { + const sanitizedArray = value.map((entry) => + manuallySanitizeValue(entry, seen), + ) + seen.delete(value) + return sanitizedArray + } + + const sanitizedObject: Record = {} + for (const [key, entry] of Object.entries(value)) { + const sanitizedEntry = manuallySanitizeValue(entry, seen) + if (sanitizedEntry !== undefined) { + sanitizedObject[key] = sanitizedEntry + } + } + + seen.delete(value) + return sanitizedObject +} + +export function toStructuredCloneSafe(value: T): T { + if (typeof structuredClone === "function") { + try { + return structuredClone(value) + } catch { + // Fall through to best-effort sanitization. + } + } + + return manuallySanitizeValue(value, new WeakSet()) as T +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return String(error) +} diff --git a/tests/SolverWorker.test.ts b/tests/SolverWorker.test.ts new file mode 100644 index 0000000..64b0f0b --- /dev/null +++ b/tests/SolverWorker.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test" +import { BaseSolver } from "../lib/BaseSolver" +import { SolverWorkerClient, exposeSolverWorker } from "../lib/SolverWorker" + +type CountingSolverInput = { + start: number + target: number +} + +class CountingSolver extends BaseSolver { + current: number + target: number + initialParams: CountingSolverInput + + constructor(params: CountingSolverInput) { + super() + this.current = params.start + this.target = params.target + this.initialParams = params + } + + override _step() { + this.current += 1 + this.stats = { current: this.current } + + if (this.current >= this.target) { + this.solved = true + } + } + + computeProgress() { + return this.current / this.target + } + + override getOutput() { + return { value: this.current } + } + + override getConstructorParams() { + return [this.initialParams] + } + + getDoubleCurrent() { + return this.current * 2 + } + + override preview() { + return { + points: [{ x: this.current, y: 0 }], + lines: [], + rects: [], + circles: [], + } + } +} + +class ParentSolver extends BaseSolver { + label = "parent" + visibleValues = [1, 2, 3] + _privateValue = "hidden" + + override _setup() { + this.activeSubSolver = new CountingSolver({ start: 0, target: 1 }) + } +} + +function createWorkerHarness() { + const channel = new MessageChannel() + const disposeWorker = exposeSolverWorker({ + solverClass: CountingSolver, + target: channel.port1, + }) + const client = new SolverWorkerClient(channel.port2) + + return { + client, + cleanup() { + disposeWorker() + client.terminate() + channel.port1.close() + channel.port2.close() + }, + } +} + +test("BaseSolver exposes a serializable snapshot contract", () => { + const solver = new ParentSolver() + solver.setup() + + const snapshot = solver.getSerializableSnapshot() + + expect(snapshot.solverName).toBe("ParentSolver") + expect(snapshot.state).toMatchObject({ + label: "parent", + visibleValues: [1, 2, 3], + }) + expect(snapshot.state).not.toHaveProperty("_privateValue") + expect(snapshot.activeSubSolver?.solverName).toBe("CountingSolver") +}) + +test("SolverWorkerClient mirrors the common BaseSolver lifecycle asynchronously", async () => { + const { client, cleanup } = createWorkerHarness() + + try { + const initSnapshot = await client.init({ start: 0, target: 4 }) + expect(initSnapshot.iterations).toBe(0) + expect(initSnapshot.state).toMatchObject({ + current: 0, + target: 4, + }) + + const firstStepSnapshot = await client.step() + expect(firstStepSnapshot.iterations).toBe(1) + expect(client.iterations).toBe(1) + expect(client.progress).toBe(0.25) + + expect(await client.call("getDoubleCurrent")).toBe(2) + expect(await client.getConstructorParams()).toEqual([ + { start: 0, target: 4 }, + ]) + expect(await client.preview()).toMatchObject({ + points: [{ x: 1, y: 0 }], + }) + + const solvedSnapshot = await client.solve() + expect(solvedSnapshot.solved).toBe(true) + expect(client.solved).toBe(true) + expect(await client.getOutput()).toEqual({ value: 4 }) + + await client.dispose() + expect(client.snapshot).toBeNull() + await expect(client.step()).rejects.toThrow( + "Solver has not been initialized", + ) + } finally { + cleanup() + } +}) + +test("SolverWorkerClient can stream periodic snapshots during solve", async () => { + const { client, cleanup } = createWorkerHarness() + + try { + await client.init({ start: 0, target: 5 }) + + const observedIterations: number[] = [] + const unsubscribe = client.subscribe((snapshot) => { + observedIterations.push(snapshot.iterations) + }) + + await client.solve({ snapshotEvery: 2 }) + unsubscribe() + + expect(observedIterations).toContain(0) + expect(observedIterations).toContain(2) + expect(observedIterations).toContain(4) + expect(observedIterations.at(-1)).toBe(5) + } finally { + cleanup() + } +})