From 4172672c5f4d35a5192e0cd7912bb67a3530a29e Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:55:16 +0200 Subject: [PATCH 1/2] fix(occt): treat a broken wasm function table as a dead heap Signature mismatch was stored as an ordinary feature failure, so the worker stayed up and the viewer never got a mesh. Classify it as poison so recompute resets and retries once. If that retry is still poisoned, throw so the host recycles the process. --- src/kernel/backends/occt/occtException.test.ts | 7 +++++++ src/modeling/capture/featureMeshing.ts | 8 +++++++- src/shared/occt/wasmPoison.ts | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/kernel/backends/occt/occtException.test.ts b/src/kernel/backends/occt/occtException.test.ts index 7824e8afa..cb9bec5dc 100644 --- a/src/kernel/backends/occt/occtException.test.ts +++ b/src/kernel/backends/occt/occtException.test.ts @@ -115,6 +115,13 @@ describe('isOcctWasmPoisoned', () => { expect(isOcctWasmPoisoned(err)).toBe(true); }); + it('recognizes a broken wasm function table', () => { + const err = new Error('null function or function signature mismatch'); + err.name = 'RuntimeError'; + expect(isOcctWasmPoisoned(err)).toBe(true); + expect(isOcctWasmPoisoned(new Error('RuntimeError: null function or function signature mismatch'))).toBe(true); + }); + it('recognizes worker-wrapped Error messages', () => { expect(isOcctWasmPoisoned(new Error('RuntimeError: memory access out of bounds'))).toBe(true); }); diff --git a/src/modeling/capture/featureMeshing.ts b/src/modeling/capture/featureMeshing.ts index ae3e1c9b8..4ab040b9a 100644 --- a/src/modeling/capture/featureMeshing.ts +++ b/src/modeling/capture/featureMeshing.ts @@ -14,6 +14,7 @@ import { OcctLowerer } from '../backends/occt/occtLowerer'; import type { FeatureEvent } from '../compute/featureEvents'; import { OcctBackend, initOcct } from '../../kernel/backends/occt/occtBackend'; import { RecomputeEngine } from '../compute/recomputeEngine'; +import { WASM_POISON_MARKER } from '../../shared/occt/wasmPoison'; import { meshShape } from '../../kernel/backends/occt/meshing'; import { isSceneBackend } from '../../kernel/backends/sceneBackend'; import { generatePlanarUVs } from './planarUv'; @@ -808,7 +809,7 @@ export async function meshFeaturesPerFeature( const linkState = newLinkState(records, seedShapes); - await engine.run(records, { + const recompute = await engine.run(records, { paramTable, ...(seedShapes !== undefined && seedShapes.size > 0 ? { seedShapes } : {}), onEvent: (event) => handleMeshFeatureEvent(event, { @@ -830,6 +831,11 @@ export async function meshFeaturesPerFeature( }), }); + // resetOcct + one retry still failed. A per-feature failure list would keep + // this process's dead heap in service; throw so the host recycles it. + const poison = recompute.diagnostics.find((d) => d.message.includes(WASM_POISON_MARKER)); + if (poison) throw new Error(poison.message); + reEmitSeededFeatureMeshes( records, seedShapes, diff --git a/src/shared/occt/wasmPoison.ts b/src/shared/occt/wasmPoison.ts index f41805ac0..2a18a7bb2 100644 --- a/src/shared/occt/wasmPoison.ts +++ b/src/shared/occt/wasmPoison.ts @@ -19,6 +19,11 @@ function looksLikeWasmPoisonText(text: string): boolean { // Emscripten abort() after unrecoverable wasm failure (truncated BREP, etc.). if (t.includes('aborted()') || t.includes('aborted(')) return true; if (t.includes('runtimeerror') && t.includes('aborted')) return true; + // Function-table corruption. Prod OCCT workers threw + // "RuntimeError: null function or function signature mismatch" and every + // later feature was recorded as a normal compile failure, so the worker + // stayed up and the viewer never painted. + if (t.includes('function signature mismatch') || t.includes('null function')) return true; return false; } From 93cb08550cd7e37930d72d472cf23643cabed668 Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:50:30 +0200 Subject: [PATCH 2/2] fix(occt): throw when a poisoned heap survives reset Returning a per-feature diagnostic kept the dead worker in service. meshFeaturesPerFeature stays under the line limit; the throw is what the host recycles on. --- src/modeling/capture/featureMeshing.ts | 8 +------- src/modeling/compute/recomputeEngine.ts | 22 +++++----------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/modeling/capture/featureMeshing.ts b/src/modeling/capture/featureMeshing.ts index 4ab040b9a..ae3e1c9b8 100644 --- a/src/modeling/capture/featureMeshing.ts +++ b/src/modeling/capture/featureMeshing.ts @@ -14,7 +14,6 @@ import { OcctLowerer } from '../backends/occt/occtLowerer'; import type { FeatureEvent } from '../compute/featureEvents'; import { OcctBackend, initOcct } from '../../kernel/backends/occt/occtBackend'; import { RecomputeEngine } from '../compute/recomputeEngine'; -import { WASM_POISON_MARKER } from '../../shared/occt/wasmPoison'; import { meshShape } from '../../kernel/backends/occt/meshing'; import { isSceneBackend } from '../../kernel/backends/sceneBackend'; import { generatePlanarUVs } from './planarUv'; @@ -809,7 +808,7 @@ export async function meshFeaturesPerFeature( const linkState = newLinkState(records, seedShapes); - const recompute = await engine.run(records, { + await engine.run(records, { paramTable, ...(seedShapes !== undefined && seedShapes.size > 0 ? { seedShapes } : {}), onEvent: (event) => handleMeshFeatureEvent(event, { @@ -831,11 +830,6 @@ export async function meshFeaturesPerFeature( }), }); - // resetOcct + one retry still failed. A per-feature failure list would keep - // this process's dead heap in service; throw so the host recycles it. - const poison = recompute.diagnostics.find((d) => d.message.includes(WASM_POISON_MARKER)); - if (poison) throw new Error(poison.message); - reEmitSeededFeatureMeshes( records, seedShapes, diff --git a/src/modeling/compute/recomputeEngine.ts b/src/modeling/compute/recomputeEngine.ts index a7115f0dc..542a1fbe9 100644 --- a/src/modeling/compute/recomputeEngine.ts +++ b/src/modeling/compute/recomputeEngine.ts @@ -594,24 +594,12 @@ export class RecomputeEngine { return await this.runPass(records, opts); } catch (retryErr) { if (!isOcctWasmPoisoned(retryErr)) throw retryErr; - const message = + // Throw, do not return a per-feature diagnostic. A returned failure + // leaves this dead heap in service and the viewer never gets a mesh. + throw new Error( `${WASM_POISON_MARKER}: ${describeOcctThrow(retryErr)}. ` + - 'OCCT was reset and the full recompute retried; still failing. Restart the host process.'; - return { - shapes: new Map(), - diagnostics: [{ - target: this.lowerer.target, - code: 'recompute.lowering.exception', - severity: 'error', - message, - hint: - 'The OCCT wasm heap was corrupted (often by an embind double-free). ' + - 'Reset + retry did not recover; restart the Node/export process or reload Studio.', - }], - health: new Map(), - mechanism: 'unverified', - mechanismFailures: [], - }; + 'OCCT was reset and the full recompute retried; still failing. Restart the host process.', + ); } } }