From 9ada609f160f0e1f8620f6f5f269e730fd56547c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:55:38 +0000 Subject: [PATCH 1/3] docs(runtime): keep GetSnapshotWithError supported GetSnapshotWithError was added in this unreleased cycle and deprecated again before any release. Keep it as the supported checked capture method; only the context-free GetSnapshot and LoadSnapshot stay deprecated. Name those two explicitly in the docs and record the deprecations in the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyRBcc1W1QuigZPCFx26zm --- CHANGELOG.md | 4 ++++ docs/instrumentation.md | 3 ++- docs/runtime.md | 5 +++-- experimental/machine.go | 4 ++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 229d853..4239128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Runtime: optional context-aware snapshot capture/restoration and invoke lifecycle interfaces; reject synchronous callback reentry when callers preserve the callback context. - Debugger bot: cancellable event processing, checked snapshot errors, and an optional history retention limit. +### Deprecated + +- Runtime: the context-free machine `GetSnapshot` and `LoadSnapshot` methods. Use `instrumentation.GetSnapshotContext` and `instrumentation.LoadSnapshotContext` outside callbacks, or `instrumentation.GetSnapshotWithError(args)` inside a synchronous action. Checked capture with `GetSnapshotWithError` remains supported. + ### Security - Studio: metadata pointer writes and object merges use own data properties, preserving JSON keys without traversing inherited properties. diff --git a/docs/instrumentation.md b/docs/instrumentation.md index bb6615a..bc36907 100644 --- a/docs/instrumentation.md +++ b/docs/instrumentation.md @@ -235,7 +235,8 @@ type UniversesResume struct { Snapshots are safe to serialize and reload using `instrumentation.LoadSnapshotContext`. Inside a synchronous action, capture through `instrumentation.GetSnapshotWithError(args)`. -The machine's context-free snapshot methods are deprecated; callback args remain supported. +The machine's `GetSnapshot` and `LoadSnapshot` methods are deprecated; checked capture with +`GetSnapshotWithError` and callback args remain supported. ## Putting the Interfaces to Work diff --git a/docs/runtime.md b/docs/runtime.md index cf3f2fe..0094ac7 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -260,8 +260,9 @@ The exported helpers `instrumentation.GetSnapshotContext(ctx, qm)` and `instrumentation.LoadSnapshotContext(ctx, qm, snapshot, machineContext)` select that capability without a type assertion at each call site. Capture falls back to checked capture and then the legacy method for custom implementations, rejects nil snapshots, and checks cancellation before -and after capture. Legacy fallbacks cannot interrupt lock waiting or detect reentry. Context-free -machine snapshot methods are deprecated; checked action args remain supported. +and after capture. Legacy fallbacks cannot interrupt lock waiting or detect reentry. The machine's +`GetSnapshot` and `LoadSnapshot` methods are deprecated; checked capture with `GetSnapshotWithError` +and checked action args remain supported. For a compiled application example with explicit budgets, callback-safe capture, a cooperative invoke, and bounded shutdown, see [`ExampleNewQuantumMachineWithOptions`](../runtime_example_test.go). diff --git a/experimental/machine.go b/experimental/machine.go index 2896fac..b5be04c 100644 --- a/experimental/machine.go +++ b/experimental/machine.go @@ -217,8 +217,8 @@ func (qm *ExQuantumMachine) GetSnapshot() *instrumentation.MachineSnapshot { } // GetSnapshotWithError returns a complete snapshot or an error, never a partial snapshot. -// -// Deprecated: use GetSnapshotContext for cancellable waiting and callback reentry detection. +// It waits for the machine lock without a context; use GetSnapshotContext when the caller +// needs cancellable waiting or callback reentry detection. func (qm *ExQuantumMachine) GetSnapshotWithError() (*instrumentation.MachineSnapshot, error) { if qm == nil { return nil, fmt.Errorf("quantum machine must not be nil") From 52c4bf6432f0eef507879468934a09f8867d0ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:58:26 +0000 Subject: [PATCH 2/3] fix(studio): keep public history checkpoints caller-owned #26 let commit-snapshot take ownership of any snapshot returned by the public createHistorySnapshot, to skip a second clone. That contradicts the contract documented in docs/studio-performance.md (supplied checkpoints are mutable and caller-owned): a consumer that mutated a checkpoint after committing it silently changed what undo restored. createHistorySnapshot is copied on commit again. Ownership transfer is now an internal, non-exported mechanism used only for the editor's own drag/gesture checkpoint, which is never exposed, so the editor keeps the single-clone gain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyRBcc1W1QuigZPCFx26zm --- CHANGELOG.md | 2 +- .../editor-core/src/StateProEditor.tsx | 4 ++- .../__tests__/editorHistoryReducer.test.ts | 26 ++++++++++++++----- .../src/state/editorHistoryReducer.ts | 18 +++++-------- .../editor-core/src/state/historyOwnership.ts | 13 ++++++++++ 5 files changed, 42 insertions(+), 21 deletions(-) create mode 100644 studio/packages/editor-core/src/state/historyOwnership.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4239128..128e353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Studio: include SVG declarations when typechecking the Web Component against editor-core source. - Studio: a malformed `value`/`defaultValue` definition no longer crashes the editor. The initial render falls back to an empty machine, a controlled update keeps the current graph, and both report the error through `console.error`. - Studio: `@rendis/statepro-studio-react/styles.css` no longer contains `@tailwind` directives. The host app's Tailwind entry generates the utilities, as documented; under Tailwind 4 the directives emitted extra utilities outside any cascade layer. -- Studio: committing a gesture checkpoint created by `createHistorySnapshot` no longer clones the graph a second time. +- Studio: committing the editor's internal drag/gesture checkpoint no longer clones the graph a second time. Checkpoints from the public `createHistorySnapshot` remain caller-owned and are still copied on `commit-snapshot`. ## [3.3.1] - 2026-08-25 diff --git a/studio/packages/editor-core/src/StateProEditor.tsx b/studio/packages/editor-core/src/StateProEditor.tsx index cb3b844..c627b97 100644 --- a/studio/packages/editor-core/src/StateProEditor.tsx +++ b/studio/packages/editor-core/src/StateProEditor.tsx @@ -49,6 +49,7 @@ import { createInitialEditorHistoryState, editorHistoryReducer, } from "./state"; +import { markHistoryOwned } from "./state/historyOwnership"; import type { EditorAction, EditorHistorySnapshot, @@ -831,7 +832,8 @@ function StateProEditorInner({ }, [isControlled, libraryBehaviors, locale]); const captureGestureBaseSnapshot = useCallback(() => { - gestureBaseSnapshotRef.current = createHistorySnapshot(editorState); + // Private copy, never exposed: the history can keep it without cloning again. + gestureBaseSnapshotRef.current = markHistoryOwned(createHistorySnapshot(editorState)); }, [editorState]); const commitGestureHistoryStep = useCallback(() => { diff --git a/studio/packages/editor-core/src/__tests__/editorHistoryReducer.test.ts b/studio/packages/editor-core/src/__tests__/editorHistoryReducer.test.ts index e783a95..7963b13 100644 --- a/studio/packages/editor-core/src/__tests__/editorHistoryReducer.test.ts +++ b/studio/packages/editor-core/src/__tests__/editorHistoryReducer.test.ts @@ -9,6 +9,7 @@ import { editorHistoryReducer, } from "../state"; import type { EditorHistoryState } from "../state"; +import { markHistoryOwned } from "../state/historyOwnership"; const applyMachineId = ( state: EditorHistoryState, @@ -73,19 +74,30 @@ describe("editorHistoryReducer", () => { expect(universe(edited.past[0].nodes).data.id).toBe(originalId); }); - it("commit-snapshot toma propiedad del checkpoint desacoplado sin clonarlo de nuevo", () => { + it("commit-snapshot copia checkpoints publicos: mutarlos despues no corrompe el undo", () => { const initial = createInitialEditorState(); + const originalId = initial.machineConfig.id; const captured = createHistorySnapshot(initial); const edited = applyMachineId(createInitialEditorHistoryState(initial), "edited", "silent"); const committed = editorHistoryReducer(edited, { type: "commit-snapshot", payload: captured }); + expect(committed.past[0]).not.toBe(captured); + + captured.machineConfig.id = "consumer-mutated"; + const restored = editorHistoryReducer(committed, { type: "undo" }); + expect(restored.present.machineConfig.id).toBe(originalId); + }); + + it("commit-snapshot toma propiedad de checkpoints internos del editor sin clonarlos", () => { + const initial = createInitialEditorState(); + const owned = markHistoryOwned(createHistorySnapshot(initial)); + const edited = applyMachineId(createInitialEditorHistoryState(initial), "edited", "silent"); + const committed = editorHistoryReducer(edited, { type: "commit-snapshot", payload: owned }); expect(committed.past).toHaveLength(1); - expect(committed.past[0]).toBe(captured); + expect(committed.past[0]).toBe(owned); - // Plain payloads are not owned by the history and are still cloned. - const external = { ...createHistorySnapshot(initial) }; - const committedExternal = editorHistoryReducer(edited, { type: "commit-snapshot", payload: external }); - expect(committedExternal.past[0]).not.toBe(external); - expect(committedExternal.past[0]).toEqual(external); + // Ownership transfers once; committing the same object again copies it. + const again = editorHistoryReducer(edited, { type: "commit-snapshot", payload: owned }); + expect(again.past[0]).not.toBe(owned); }); it("permite cambios visuales sin marcar dirty-from-import cuando se indica explícitamente", () => { diff --git a/studio/packages/editor-core/src/state/editorHistoryReducer.ts b/studio/packages/editor-core/src/state/editorHistoryReducer.ts index eedba31..c2c4aa5 100644 --- a/studio/packages/editor-core/src/state/editorHistoryReducer.ts +++ b/studio/packages/editor-core/src/state/editorHistoryReducer.ts @@ -11,6 +11,7 @@ import type { StateProMachine, } from "../types"; import { editorReducer, type EditorAction } from "./editorReducer"; +import { takeHistoryOwnership } from "./historyOwnership"; import deepEqual from "fast-deep-equal"; export const HISTORY_LIMIT = 100; @@ -81,15 +82,11 @@ const snapshotsEqual = ( ); }; -// Snapshots from createHistorySnapshot are already detached copies. The first -// commit takes ownership of them instead of cloning the whole graph again. -const detachedSnapshots = new WeakSet(); - const pushPast = ( past: EditorHistorySnapshot[], snapshot: EditorHistorySnapshot, ): EditorHistorySnapshot[] => { - const stored = detachedSnapshots.delete(snapshot) ? snapshot : clone(snapshot); + const stored = takeHistoryOwnership(snapshot) ? snapshot : clone(snapshot); const nextPast = [...past, stored]; if (nextPast.length <= HISTORY_LIMIT) { return nextPast; @@ -164,14 +161,11 @@ const shouldAutoMarkDirtyFromImport = ( }; /** - * Returns a snapshot detached from `state`. Committing it with `commit-snapshot` - * transfers ownership to the history without another clone; do not mutate it afterwards. + * Returns a snapshot detached from `state`. The caller keeps owning it: committing it + * with `commit-snapshot` stores a copy, so later mutations never reach the history. */ -export const createHistorySnapshot = (state: EditorState): EditorHistorySnapshot => { - const snapshot = clone(toSnapshot(state)); - detachedSnapshots.add(snapshot); - return snapshot; -}; +export const createHistorySnapshot = (state: EditorState): EditorHistorySnapshot => + clone(toSnapshot(state)); export const createInitialEditorHistoryState = ( initialState: EditorState = createInitialEditorState(), diff --git a/studio/packages/editor-core/src/state/historyOwnership.ts b/studio/packages/editor-core/src/state/historyOwnership.ts new file mode 100644 index 0000000..667c644 --- /dev/null +++ b/studio/packages/editor-core/src/state/historyOwnership.ts @@ -0,0 +1,13 @@ +// Internal to editor-core and intentionally not re-exported from the package. +// Public checkpoints stay caller-owned and are always copied on commit; only +// private detached copies created by the editor itself are registered here, so +// the history can take ownership of them instead of cloning the graph again. +const ownedSnapshots = new WeakSet(); + +export const markHistoryOwned = (snapshot: T): T => { + ownedSnapshots.add(snapshot); + return snapshot; +}; + +/** Returns true once for a registered snapshot, transferring it to the history. */ +export const takeHistoryOwnership = (snapshot: object): boolean => ownedSnapshots.delete(snapshot); From edde06693e3a6b27890ba9620789a7f5b7dd8cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:04:53 +0000 Subject: [PATCH 3/3] perf(studio): reuse the layout worker and fall back when it cannot load The worker path from #25 created, initialized, and terminated a new ELK worker for every layout, so each layout paid 200-400 ms of engine startup and small graphs were slower than on the main thread. If the worker could not load (404, worker-src CSP, cross-origin asset URL), every layout failed with only a console error. - One worker per URL is reused, including for concurrent requests (elk-api tags each message with an id), and released after 60 s idle. - If the worker cannot start or load, the request falls back to the bundled engine on the main thread and the URL is not retried this page session. A worker that crashes after succeeding is discarded and recreated next time. - A 30 s timeout still rejects without falling back, discarding the worker, since rerunning a stuck graph on the main thread would freeze the UI. Verified in Chromium against the real elk worker: initial plus three manual layouts used a single worker with no errors; a broken worker URL was tried once, fell back, and layouts kept working. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyRBcc1W1QuigZPCFx26zm --- CHANGELOG.md | 1 + docs/studio-performance.md | 17 +- .../src/__tests__/autoLayoutWorker.test.ts | 157 ++++++++++++---- .../editor-core/src/utils/autoLayout.ts | 168 ++++++++++++++++-- 4 files changed, 285 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128e353..f0967fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Studio: include SVG declarations when typechecking the Web Component against editor-core source. - Studio: a malformed `value`/`defaultValue` definition no longer crashes the editor. The initial render falls back to an empty machine, a controlled update keeps the current graph, and both report the error through `console.error`. - Studio: `@rendis/statepro-studio-react/styles.css` no longer contains `@tailwind` directives. The host app's Tailwind entry generates the utilities, as documented; under Tailwind 4 the directives emitted extra utilities outside any cascade layer. +- Studio: the ELK layout worker is reused across layouts instead of being created and initialized per request, and released after 60 seconds idle. If the worker cannot load or start (missing asset, CSP, cross-origin URL), layout falls back to the bundled engine on the main thread instead of failing. - Studio: committing the editor's internal drag/gesture checkpoint no longer clones the graph a second time. Checkpoints from the public `createHistorySnapshot` remain caller-owned and are still copied on `commit-snapshot`. ## [3.3.1] - 2026-08-25 diff --git a/docs/studio-performance.md b/docs/studio-performance.md index 9b227a2..e93f1d3 100644 --- a/docs/studio-performance.md +++ b/docs/studio-performance.md @@ -36,11 +36,18 @@ element.autoLayoutWorkerUrl = "/assets/elk-worker.min.js"; ``` Without a worker URL, editor-core retains its lazily imported bundled ELK implementation. -Both paths preserve the layered layout algorithm and graph anchoring. Each worker-backed -request owns one worker, uses it for internal and external layout, and terminates it on -completion or failure. Load/message errors reject promptly. A request that does not finish -within 30 seconds rejects and terminates its worker. Failed imports of the lightweight API -can be retried. Workers are not retained globally after an editor has finished a request. +Both paths preserve the layered layout algorithm and graph anchoring. One worker per URL is +reused across layout requests, including concurrent ones (ELK tags each request), so the +engine is parsed and initialized once instead of on every layout. It is released after +60 seconds without layout requests and recreated on demand. + +If the worker cannot start or load (missing asset, `worker-src` CSP, a cross-origin URL, +or no `Worker` global), the request falls back to the bundled engine on the main thread +and logs a warning; that URL is not retried for the rest of the page session. A worker that +crashes after completing layouts is discarded, its in-flight requests fall back, and the next +request starts a new worker. A request that does not finish within 30 seconds rejects and +discards the worker without falling back, since rerunning a stuck graph on the main thread +would freeze the UI. ELK errors for an invalid graph are returned as-is. Moving ELK off the UI thread does **not** reduce its download. The worker asset is about 1.61 MB before HTTP compression (about 467 kB gzip in the local build); the API chunk is diff --git a/studio/packages/editor-core/src/__tests__/autoLayoutWorker.test.ts b/studio/packages/editor-core/src/__tests__/autoLayoutWorker.test.ts index 7c3ffd0..989c481 100644 --- a/studio/packages/editor-core/src/__tests__/autoLayoutWorker.test.ts +++ b/studio/packages/editor-core/src/__tests__/autoLayoutWorker.test.ts @@ -6,15 +6,35 @@ const nodes: EditorNode[] = [{ data: { id: "main", name: "main", canonicalName: "main", version: "1.0.0" }, }]; -const prepare = async (pending = false, failConstructor = false) => { - const workers: Array; url: string }> = []; - class FakeWorker extends EventTarget { +type FakeWorker = EventTarget & { terminate: ReturnType; url: string }; + +type LayoutMode = "ok" | "pending" | "elk-error"; + +interface PrepareOptions { + mode?: LayoutMode; + failConstructor?: boolean; + workerThrows?: boolean; +} + +const layoutGraph = (graph: { children?: Array> }) => + ({ ...graph, children: graph.children?.map(child => ({ ...child, x: 0, y: 0 })) }); + +const prepare = async ({ mode = "ok", failConstructor = false, workerThrows = false }: PrepareOptions = {}) => { + const workers: FakeWorker[] = []; + const control = { mode }; + class FakeWorkerImpl extends EventTarget { terminate = vi.fn(); - constructor(public url: string) { super(); workers.push(this); } + constructor(public url: string) { + super(); + if (workerThrows) throw new DOMException("cross-origin worker", "SecurityError"); + workers.push(this as unknown as FakeWorker); + } } - vi.stubGlobal("Worker", FakeWorker); - const bundled = vi.fn(() => { throw new Error("bundled ELK should not load"); }); - vi.doMock("elkjs/lib/elk.bundled.js", bundled); + vi.stubGlobal("Worker", FakeWorkerImpl); + const bundledLayout = vi.fn(async (graph: { children?: Array> }) => layoutGraph(graph)); + vi.doMock("elkjs/lib/elk.bundled.js", () => ({ + default: class { layout = bundledLayout; }, + })); const construct = vi.fn(); vi.doMock("elkjs/lib/elk-api.js", () => ({ default: class { @@ -24,29 +44,33 @@ const prepare = async (pending = false, failConstructor = false) => { if (failConstructor) throw new Error("constructor failed"); } async layout(graph: { children?: Array> }) { - if (pending) return new Promise(() => {}); - return { ...graph, children: graph.children?.map(child => ({ ...child, x: 0, y: 0 })) }; + if (control.mode === "pending") return new Promise(() => {}); + if (control.mode === "elk-error") throw new Error("invalid graph"); + return layoutGraph(graph); } }, })); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { computeAutoLayout } = await import("../utils/autoLayout"); - return { computeAutoLayout, workers, bundled, construct }; + return { computeAutoLayout, workers, bundledLayout, construct, control, warn }; }; afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.doUnmock("elkjs/lib/elk-api.js"); vi.doUnmock("elkjs/lib/elk.bundled.js"); vi.resetModules(); }); -describe("Layout aislado en worker", () => { - it("usa el worker solicitado sin cargar el motor completo en el hilo principal", async () => { - const { computeAutoLayout, workers, bundled, construct } = await prepare(); +describe("Layout en worker reutilizable", () => { + it("reutiliza un solo worker para layouts secuenciales y concurrentes sin cargar el motor completo", async () => { + const { computeAutoLayout, workers, bundledLayout, construct } = await prepare(); const empty: EditorNode[] = []; expect(await computeAutoLayout(empty, [], {}, "/elk-worker.js")).toBe(empty); expect(workers).toHaveLength(0); + await computeAutoLayout(nodes, [], {}, "/elk-worker.js"); const [a, b] = await Promise.all([ computeAutoLayout(nodes, [], {}, "/elk-worker.js"), @@ -54,27 +78,67 @@ describe("Layout aislado en worker", () => { ]); expect(a[0]).toMatchObject({ x: 12, y: 24, w: 240, h: 220 }); expect(b).toEqual(a); - expect(workers).toHaveLength(3); - workers.forEach(worker => { - expect(worker.url).toBe("/elk-worker.js"); - expect(worker.terminate).toHaveBeenCalledOnce(); - }); + expect(workers).toHaveLength(1); + expect(workers[0].url).toBe("/elk-worker.js"); + expect(workers[0].terminate).not.toHaveBeenCalled(); + expect(construct).toHaveBeenCalledOnce(); expect(construct).toHaveBeenCalledWith(expect.objectContaining({ algorithms: ["layered"] })); - expect(bundled).not.toHaveBeenCalled(); + expect(bundledLayout).not.toHaveBeenCalled(); }); - it.each(["error", "messageerror"])("rechaza %s y libera el worker en vez de quedar esperando", async event => { - const { computeAutoLayout, workers } = await prepare(true); - const result = computeAutoLayout(nodes, [], {}, "/missing-worker.js"); - const rejection = expect(result).rejects.toThrow("layout worker failed"); - await vi.waitFor(() => expect(workers).toHaveLength(1)); - workers[0].dispatchEvent(new Event(event)); - await rejection; - expect(workers[0].terminate).toHaveBeenCalledOnce(); + it.each(["error", "messageerror"])( + "si el worker falla al cargar (%s) usa el motor del hilo principal y no reintenta esa URL", + async event => { + const { computeAutoLayout, workers, bundledLayout, control, warn } = await prepare({ mode: "pending" }); + const result = computeAutoLayout(nodes, [], {}, "/missing-worker.js"); + await vi.waitFor(() => expect(workers).toHaveLength(1)); + workers[0].dispatchEvent(new Event(event)); + expect((await result)[0]).toMatchObject({ x: 12, y: 24 }); + expect(workers[0].terminate).toHaveBeenCalledOnce(); + expect(bundledLayout).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalled(); + + control.mode = "ok"; + await computeAutoLayout(nodes, [], {}, "/missing-worker.js"); + expect(workers).toHaveLength(1); + expect(bundledLayout).toHaveBeenCalledTimes(2); + }, + ); + + it.each([ + ["el constructor de Worker lanza (CSP u otro origen)", { workerThrows: true }], + ["falla la construccion del motor", { failConstructor: true }], + ])("usa el motor del hilo principal si %s", async (_label, options) => { + const { computeAutoLayout, workers, bundledLayout } = await prepare(options); + expect(await computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1); + expect(bundledLayout).toHaveBeenCalledOnce(); + workers.forEach(worker => expect(worker.terminate).toHaveBeenCalledOnce()); }); - it("interrumpe un worker bloqueado y permite una solicitud posterior", async () => { - const { computeAutoLayout, workers } = await prepare(true); + it("usa el motor del hilo principal cuando Worker no existe", async () => { + const { computeAutoLayout, bundledLayout } = await prepare(); + vi.stubGlobal("Worker", undefined); + expect(await computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1); + expect(bundledLayout).toHaveBeenCalledOnce(); + }); + + it("si el worker cae tras funcionar, recupera la solicitud en curso y crea uno nuevo despues", async () => { + const { computeAutoLayout, workers, bundledLayout, control } = await prepare(); + await computeAutoLayout(nodes, [], {}, "/elk-worker.js"); + control.mode = "pending"; + const result = computeAutoLayout(nodes, [], {}, "/elk-worker.js"); + workers[0].dispatchEvent(new Event("error")); + expect(await result).toHaveLength(1); + expect(bundledLayout).toHaveBeenCalledOnce(); + + control.mode = "ok"; + await computeAutoLayout(nodes, [], {}, "/elk-worker.js"); + expect(workers).toHaveLength(2); + expect(bundledLayout).toHaveBeenCalledOnce(); + }); + + it("interrumpe un worker bloqueado sin congelar el hilo principal y el siguiente layout usa uno nuevo", async () => { + const { computeAutoLayout, workers, bundledLayout, control } = await prepare({ mode: "pending" }); vi.useFakeTimers(); const result = computeAutoLayout(nodes, [], {}, "/elk-worker.js"); const rejection = expect(result).rejects.toThrow("timed out"); @@ -82,16 +146,35 @@ describe("Layout aislado en worker", () => { await vi.advanceTimersByTimeAsync(30_000); await rejection; expect(workers[0].terminate).toHaveBeenCalledOnce(); - vi.useRealTimers(); - vi.resetModules(); - const retry = await prepare(); - expect(await retry.computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1); - expect(retry.workers[0].terminate).toHaveBeenCalledOnce(); + expect(bundledLayout).not.toHaveBeenCalled(); + + control.mode = "ok"; + expect(await computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1); + expect(workers).toHaveLength(2); + expect(workers[1].terminate).not.toHaveBeenCalled(); + }); + + it("propaga errores de ELK sin cambiar de motor y conserva el worker", async () => { + const { computeAutoLayout, workers, bundledLayout, control } = await prepare({ mode: "elk-error" }); + await expect(computeAutoLayout(nodes, [], {}, "/elk-worker.js")).rejects.toThrow("invalid graph"); + expect(bundledLayout).not.toHaveBeenCalled(); + expect(workers[0].terminate).not.toHaveBeenCalled(); + + control.mode = "ok"; + await computeAutoLayout(nodes, [], {}, "/elk-worker.js"); + expect(workers).toHaveLength(1); }); - it("libera el worker si falla la construccion del motor", async () => { - const { computeAutoLayout, workers } = await prepare(false, true); - await expect(computeAutoLayout(nodes, [], {}, "/elk-worker.js")).rejects.toThrow("constructor failed"); + it("libera el worker inactivo y crea otro en la siguiente solicitud", async () => { + const { computeAutoLayout, workers } = await prepare(); + vi.useFakeTimers(); + await computeAutoLayout(nodes, [], {}, "/elk-worker.js"); + await vi.advanceTimersByTimeAsync(59_000); + expect(workers[0].terminate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); expect(workers[0].terminate).toHaveBeenCalledOnce(); + + await computeAutoLayout(nodes, [], {}, "/elk-worker.js"); + expect(workers).toHaveLength(2); }); }); diff --git a/studio/packages/editor-core/src/utils/autoLayout.ts b/studio/packages/editor-core/src/utils/autoLayout.ts index b81988c..52a666c 100644 --- a/studio/packages/editor-core/src/utils/autoLayout.ts +++ b/studio/packages/editor-core/src/utils/autoLayout.ts @@ -72,31 +72,167 @@ const getElk = (): Promise => { return elkPromise; }; -// Each request owns its worker and releases it when finished or timed out. -// Failed workers are never shared with another layout request. +const LAYOUT_WORKER_TIMEOUT_MS = 30_000; +const LAYOUT_WORKER_IDLE_MS = 60_000; + +/** The worker could not run layouts; the request falls back to the bundled engine. */ +class LayoutWorkerUnavailableError extends Error {} +/** The worker was released on purpose (idle or URL change); its URL is still usable. */ +class LayoutWorkerReleasedError extends LayoutWorkerUnavailableError {} +class LayoutWorkerTimeoutError extends Error {} + +interface WorkerEngine { + worker: Worker; + elk: ElkInstance; + /** Rejects once the worker is discarded, so in-flight requests stop waiting on it. */ + discarded: Promise; + discard: (error: Error) => void; + pending: number; + /** Set after the first completed layout; later failures are crashes, not load failures. */ + succeeded: boolean; + idleTimer?: ReturnType; +} + +interface WorkerEngineEntry { + url: string; + promise: Promise; + engine?: WorkerEngine; +} + +// One worker per URL is reused across layouts: elk-api tags each request with an id, +// so concurrent layouts share it safely and ELK is initialized only once. +let workerEngineEntry: WorkerEngineEntry | undefined; +// URLs whose worker never completed a layout (404, CSP, cross-origin). Later layouts +// go straight to the bundled engine instead of failing or retrying the same URL. +const unavailableWorkerUrls = new Set(); + +const createWorkerEngine = async (entry: WorkerEngineEntry): Promise => { + let ElkConstructor: ElkConstructorLike; + let worker: Worker; + try { + ElkConstructor = await getWorkerConstructor(); + worker = new Worker(entry.url); + } catch (error) { + throw new LayoutWorkerUnavailableError(`StatePro Studio: layout worker could not start: ${String(error)}`); + } + + let rejectDiscarded!: (error: Error) => void; + const discarded = new Promise((_, reject) => { rejectDiscarded = reject; }); + discarded.catch(() => {}); + const onError = () => engine.discard( + new LayoutWorkerUnavailableError("StatePro Studio: layout worker failed to load or execute."), + ); + const engine: WorkerEngine = { + worker, + elk: undefined as unknown as ElkInstance, + discarded, + pending: 0, + succeeded: false, + discard: (error) => { + if (workerEngineEntry?.engine === engine) workerEngineEntry = undefined; + clearTimeout(engine.idleTimer); + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onError); + worker.terminate(); + rejectDiscarded(error); + }, + }; + entry.engine = engine; + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onError); + try { + engine.elk = new ElkConstructor({ workerFactory: () => worker, algorithms: ["layered"] }); + } catch (error) { + const unavailable = new LayoutWorkerUnavailableError( + `StatePro Studio: layout worker could not start: ${String(error)}`, + ); + engine.discard(unavailable); + throw unavailable; + } + return engine; +}; + +const getWorkerEngine = (url: string): Promise => { + if (workerEngineEntry?.url !== url) { + workerEngineEntry?.engine?.discard( + new LayoutWorkerReleasedError("StatePro Studio: layout worker URL changed."), + ); + const entry: WorkerEngineEntry = { url, promise: undefined as unknown as Promise }; + entry.promise = createWorkerEngine(entry); + entry.promise.catch(() => { + if (workerEngineEntry === entry) workerEngineEntry = undefined; + }); + workerEngineEntry = entry; + } + return workerEngineEntry.promise; +}; + +const runOnBundledEngine = async ( + run: (elk: ElkInstance) => Promise, + reason?: unknown, +): Promise => { + if (reason) { + console.warn("StatePro Studio: falling back to main-thread layout.", reason); + } + return run(await getElk()); +}; + +// Layout runs in the host-provided worker when available. If the worker cannot load +// or crashes, the request falls back to the bundled engine on the main thread. A +// timeout rejects instead: running a stuck graph on the main thread would freeze the UI. const withLayoutEngine = async ( workerUrl: string | undefined, run: (elk: ElkInstance) => Promise, ): Promise => { - if (!workerUrl) return run(await getElk()); + if (!workerUrl || typeof Worker === "undefined" || unavailableWorkerUrls.has(workerUrl)) { + return runOnBundledEngine(run); + } - const ElkConstructor = await getWorkerConstructor(); - const worker = new Worker(workerUrl); + let engine: WorkerEngine; + try { + engine = await getWorkerEngine(workerUrl); + } catch (error) { + unavailableWorkerUrls.add(workerUrl); + return runOnBundledEngine(run, error); + } + + clearTimeout(engine.idleTimer); + engine.pending += 1; let timer: ReturnType | undefined; - let rejectFailure: (error: Error) => void; - const unavailable = new Promise((_, reject) => { rejectFailure = reject; }); - const onError = () => rejectFailure(new Error("StatePro Studio: layout worker failed to load or execute.")); - worker.addEventListener("error", onError); - worker.addEventListener("messageerror", onError); + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new LayoutWorkerTimeoutError( + `StatePro Studio: layout worker timed out after ${LAYOUT_WORKER_TIMEOUT_MS / 1000} seconds.`, + )), + LAYOUT_WORKER_TIMEOUT_MS, + ); + }); try { - const elk = new ElkConstructor({ workerFactory: () => worker, algorithms: ["layered"] }); - timer = setTimeout(() => rejectFailure(new Error("StatePro Studio: layout worker timed out after 30 seconds.")), 30_000); - return await Promise.race([run(elk), unavailable]); + const result = await Promise.race([run(engine.elk), engine.discarded, timeout]); + engine.succeeded = true; + return result; + } catch (error) { + if (error instanceof LayoutWorkerUnavailableError) { + if (!engine.succeeded && !(error instanceof LayoutWorkerReleasedError)) { + unavailableWorkerUrls.add(workerUrl); + } + return runOnBundledEngine(run, error); + } + if (error instanceof LayoutWorkerTimeoutError) { + // A stuck worker is never reused; the next layout starts a fresh one. + engine.discard(error); + } + throw error; } finally { clearTimeout(timer); - worker.removeEventListener("error", onError); - worker.removeEventListener("messageerror", onError); - worker.terminate(); + engine.pending -= 1; + if (engine.pending === 0 && workerEngineEntry?.engine === engine) { + // Release the worker (and ELK's memory) once the editor stops requesting layouts. + engine.idleTimer = setTimeout( + () => engine.discard(new LayoutWorkerReleasedError("StatePro Studio: idle layout worker released.")), + LAYOUT_WORKER_IDLE_MS, + ); + } } };