@ifc-lite/geometry 7.2.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/coordinate-handler.js +4 -4
  2. package/dist/coordinate-handler.js.map +1 -1
  3. package/dist/coordinate-types.d.ts +8 -1
  4. package/dist/coordinate-types.d.ts.map +1 -1
  5. package/dist/geometry-parallel-options.d.ts +29 -0
  6. package/dist/geometry-parallel-options.d.ts.map +1 -1
  7. package/dist/geometry-parallel.d.ts.map +1 -1
  8. package/dist/geometry-parallel.js +228 -87
  9. package/dist/geometry-parallel.js.map +1 -1
  10. package/dist/geometry-worker-init.d.ts +9 -0
  11. package/dist/geometry-worker-init.d.ts.map +1 -0
  12. package/dist/geometry-worker-init.js +64 -0
  13. package/dist/geometry-worker-init.js.map +1 -0
  14. package/dist/geometry.worker.d.ts +16 -0
  15. package/dist/geometry.worker.d.ts.map +1 -1
  16. package/dist/geometry.worker.js +15 -8
  17. package/dist/geometry.worker.js.map +1 -1
  18. package/dist/hung-job-recovery.d.ts +145 -0
  19. package/dist/hung-job-recovery.d.ts.map +1 -0
  20. package/dist/hung-job-recovery.js +289 -0
  21. package/dist/hung-job-recovery.js.map +1 -0
  22. package/dist/index.d.ts +18 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +12 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/rtc-rebase.d.ts +101 -0
  27. package/dist/rtc-rebase.d.ts.map +1 -0
  28. package/dist/rtc-rebase.js +135 -0
  29. package/dist/rtc-rebase.js.map +1 -0
  30. package/dist/stall-phase.d.ts +111 -0
  31. package/dist/stall-phase.d.ts.map +1 -0
  32. package/dist/stall-phase.js +164 -0
  33. package/dist/stall-phase.js.map +1 -0
  34. package/dist/types.d.ts +6 -3
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/dist/world-frame.d.ts +44 -5
  38. package/dist/world-frame.d.ts.map +1 -1
  39. package/dist/world-frame.js +70 -6
  40. package/dist/world-frame.js.map +1 -1
  41. package/package.json +9 -4
@@ -1,6 +1,7 @@
1
1
  /* This Source Code Form is subject to the terms of the Mozilla Public
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ import { postGeometryWorkerInit } from './geometry-worker-init.js';
4
5
  import { mergeGeometryDiagnostics } from './diagnostics.js';
5
6
  import { computeWorkerCount } from './worker-count.js';
6
7
  import { notifyIfWasmAssetUnavailable, notifyIfWorkerScriptUnavailable } from './wasm-asset-error.js';
@@ -11,6 +12,8 @@ import { restashWasmPanicLocation } from './wasm-panic-forward.js';
11
12
  import { compileSharedWasmModule } from './wasm-shared-module.js';
12
13
  import { stitchShards } from './shard-stitch.js';
13
14
  import { resolveRtcFrame } from './rtc-frame.js';
15
+ import { emptyStylesPrepassEvent, frozenStallPhase, GateTracker, PhaseBoundTimers, preWorkerPhaseFailureDiagnostics, } from './stall-phase.js';
16
+ import { SkippedHungElementsCollector, startHungJobMonitor, WorkerJobLedger, } from './hung-job-recovery.js';
14
17
  /**
15
18
  * Prepass class-byte layout, mirroring the `PREPASS_CLASS_*` definitions in
16
19
  * `rust/processing/src/shard_classes.rs` (the source of truth — these are
@@ -160,6 +163,23 @@ function terminateWorkerQuietly(worker, label) {
160
163
  console.warn(`[stream] ${label} terminate failed:`, err);
161
164
  }
162
165
  }
166
+ /**
167
+ * A recorded `set-entity-index` setup (#4884). Built OUTSIDE `deliverEntityIndex`
168
+ * on purpose: a replay closure created in that scope would keep the pre-pass's
169
+ * transferred id/start/length columns (12 B per entity, ~180 MB on a 1 GB file)
170
+ * alive for the whole load, where before they were collectable right after the
171
+ * copy into the shared buffers. This one captures only its `columns` factory.
172
+ */
173
+ function entityIndexSetup(columns) {
174
+ return (w) => {
175
+ try {
176
+ w.postMessage({ type: 'set-entity-index', ...columns() });
177
+ }
178
+ catch (err) {
179
+ console.warn('[stream] set-entity-index dispatch failed:', err);
180
+ }
181
+ };
182
+ }
163
183
  let nextSourceSessionId = 0;
164
184
  export async function* processParallel(buffer, coordinator, sharedRtcOffset,
165
185
  /** Optional pre-allocated SAB the caller already shares with another worker. */
@@ -204,6 +224,13 @@ existingSab, options) {
204
224
  resolveWaiting = null;
205
225
  }
206
226
  };
227
+ // Which pre-worker gate is still open (#4902) — see stall-phase.ts.
228
+ const gateTracker = new GateTracker();
229
+ if (options?.stallPhaseHandle)
230
+ options.stallPhaseHandle.getStallPhase = () => gateTracker.getStallPhase();
231
+ // Bounded waits for those gates (#4902); cleared in the `finally` below so
232
+ // none outlives this load's teardown — see stall-phase.ts.
233
+ const phaseBoundTimers = new PhaseBoundTimers();
207
234
  // Pre-pass worker drives the entire pipeline via streaming events.
208
235
  let prepassMeta = null;
209
236
  let prepassJobsTotal = 0;
@@ -247,6 +274,10 @@ existingSab, options) {
247
274
  const shardResults = [];
248
275
  let shardResultsRemaining = 0;
249
276
  let shardScanDispatchedAt = -1;
277
+ // #4902: settles once, normally or via the bounded-wait timeout below.
278
+ let shardScanSettled = false;
279
+ let stylesSlicesSettled = false;
280
+ let finalizeSettled = false;
250
281
  const stylesSliceResults = [];
251
282
  let stylesSlicesRemaining = 0;
252
283
  // Support spans extracted from the shard classes (sharded mode only).
@@ -277,7 +308,11 @@ existingSab, options) {
277
308
  // crash from the worker SCRIPT failing to load (stale-deploy 404, #1251).
278
309
  let workerSpoke = false;
279
310
  worker.onmessage = (e) => {
311
+ // A worker replaced after a hang (#4884) may still flush queued messages.
312
+ if (workers[workerIndex] !== worker)
313
+ return;
280
314
  workerSpoke = true;
315
+ ledger.onHeard(workerIndex, performance.now());
281
316
  const msg = e.data;
282
317
  if (msg.type === 'ready') {
283
318
  console.log(`[stream] worker[${workerIndex}] WASM ready @ ${elapsed()}ms`);
@@ -307,6 +342,10 @@ existingSab, options) {
307
342
  return;
308
343
  }
309
344
  if (msg.type === 'styles-final') {
345
+ // A late reply after the #4902 finalize bound already drained with
346
+ // default colours must not reopen that gate.
347
+ if (finalizeSettled)
348
+ return;
310
349
  // Finalized styles payload from worker 0 — feed it through the SAME
311
350
  // prepass styles-event path (gates, logging, distribution) by
312
351
  // synthesizing a prepass-stream message. The handler is a plain
@@ -347,10 +386,19 @@ existingSab, options) {
347
386
  // Forward it so the consumer's stall watchdog measures "one WASM
348
387
  // call went silent", not "no meshes lately" — a CSG-heavy stretch
349
388
  // can keep every worker busy past the watchdog with nothing to show.
389
+ if (typeof msg.seq === 'number') {
390
+ ledger.onCallStart(workerIndex, msg.seq, msg.processedJobs, msg.callJobs ?? 1, performance.now());
391
+ }
392
+ if (msg.diagnostics)
393
+ diagnostics = mergeGeometryDiagnostics(diagnostics, msg.diagnostics);
350
394
  eventQueue.push({ type: 'progress', phase: 'workers' });
351
395
  wake();
352
396
  return;
353
397
  }
398
+ if (msg.type === 'slice-done') {
399
+ ledger.onSliceDone(workerIndex, msg.seq, performance.now());
400
+ return;
401
+ }
354
402
  if (msg.type === 'batch') {
355
403
  if (firstBatchByWorker[workerIndex] === undefined) {
356
404
  firstBatchByWorker[workerIndex] = elapsed();
@@ -444,6 +492,8 @@ existingSab, options) {
444
492
  }
445
493
  };
446
494
  worker.onerror = (err) => {
495
+ if (workers[workerIndex] !== worker)
496
+ return;
447
497
  // A hard worker crash (e.g. the wasm thread aborting under memory
448
498
  // pressure) fires an ErrorEvent with an empty `message`. Emitting the
449
499
  // literal "undefined" produced a cryptic, unclassifiable error in tracking
@@ -489,8 +539,25 @@ existingSab, options) {
489
539
  console.log('[stream] shared wasm module compiled once — workers initSync (no per-worker recompile)');
490
540
  }
491
541
  const workers = [];
542
+ // Hung-call recovery (#4884): unfinished slices per worker, and every per-load
543
+ // state message in order (never per-worker work) to rebuild a replacement.
544
+ // Opt-in: without a budget nothing is recorded, so the pool costs what it did.
545
+ const hungJobTimeoutMs = Math.max(0, options?.hungJobTimeoutMs ?? 0);
546
+ const ledger = new WorkerJobLedger(workerCount, performance.now(), hungJobTimeoutMs > 0);
547
+ // Always recorded (#4902 review): a pre-worker phase bound below may need
548
+ // to replace a worker regardless of whether #4884's in-call recovery is
549
+ // enabled, and replaying this log is how a replacement reaches the same
550
+ // state every other worker is in.
551
+ const workerSetup = [];
552
+ const broadcastSetup = (setup) => {
553
+ workerSetup.push(setup);
554
+ for (const w of workers)
555
+ setup(w);
556
+ };
557
+ const postInitMessages = (worker) => postGeometryWorkerInit(worker, options, sharedWasmModule);
558
+ workerSetup.push(postInitMessages);
492
559
  // This loop runs BEFORE the try/finally below (which owns teardown for the
493
- // rest of the pipeline), so it needs its own: `postMessage` below can throw
560
+ // rest of the pipeline), so it needs its own: `postInitMessages` can throw
494
561
  // (e.g. a `wasmModule` structured-clone failure — the same class of error
495
562
  // `dispatchJobsChunkInternal` already guards against further down), and
496
563
  // without this try/catch any worker already pushed to `workers` before the
@@ -500,58 +567,7 @@ existingSab, options) {
500
567
  const worker = makeGeometryWorker();
501
568
  workers.push(worker);
502
569
  installWorkerHandlers(worker, i);
503
- // Instantiate WASM. When the host compiled the module once (above), each
504
- // worker `initSync`s it (cheap); otherwise it falls back to compiling from
505
- // bytes. The worker's tail-promise serialiser guarantees this `init`
506
- // completes before any subsequent `stream-start`/`stream-chunk` runs.
507
- //
508
- // `wasmUrl` is forwarded only when the consumer explicitly provided one AND
509
- // no shared module is available — undefined leaves the worker on
510
- // wasm-bindgen's default `import.meta.url`-based resolution (Vite + webpack).
511
- const wasmUrlForWorker = options?.wasmUrls?.wasm;
512
- worker.postMessage({
513
- type: 'init',
514
- ...(sharedWasmModule
515
- ? { wasmModule: sharedWasmModule }
516
- : wasmUrlForWorker
517
- ? { wasmUrl: wasmUrlForWorker }
518
- : {}),
519
- });
520
- // Issue #540: forward the user's "Merge Multilayer Walls" toggle
521
- // BEFORE any stream-start so the worker's IfcAPI has the flag set
522
- // before its first parse call. The tail-promise serialiser inside
523
- // each worker preserves this order even though the messages are
524
- // posted back-to-back. We always send the message so the controller
525
- // path doesn't have to remember whether the host called it — the
526
- // default `false` is a cheap no-op.
527
- worker.postMessage({
528
- type: 'set-merge-layers',
529
- enabled: options?.mergeLayers === true,
530
- });
531
- // GPU-instancing partition toggle — default ON; the host sets false for federated
532
- // loads so a federated model's geometry stays flat (instancing is primary-only).
533
- worker.postMessage({
534
- type: 'set-instancing-enabled',
535
- enabled: options?.enableInstancing !== false,
536
- });
537
- // Issue #924: forward the geometry-hash tolerance the same way — always
538
- // sent so the controller path stays uniform; null is a cheap no-op.
539
- worker.postMessage({
540
- type: 'set-compute-geometry-hashes',
541
- tolerance: options?.geometryHashTolerance ?? null,
542
- });
543
- // Issue #976: forward the tessellation-quality level the same way —
544
- // null keeps the Rust default (Medium / historical densities).
545
- worker.postMessage({
546
- type: 'set-tessellation-quality',
547
- level: options?.tessellationQuality ?? null,
548
- });
549
- // Issue #1286: forward the small-cut skip the same way — always sent so a
550
- // worker reused by a later export (which omits it) resets to false.
551
- worker.postMessage({
552
- type: 'set-skip-small-cuts',
553
- enabled: options?.skipSmallCuts === true,
554
- });
570
+ postInitMessages(worker);
555
571
  }
556
572
  }
557
573
  catch (err) {
@@ -559,6 +575,31 @@ existingSab, options) {
559
575
  terminateWorkerQuietly(w, 'process worker (init)');
560
576
  throw err;
561
577
  }
578
+ /**
579
+ * Terminate and replace pool worker `index` (#4902 review). A silent
580
+ * `scan-shard` / `resolve-styles-shard` / `finalize-styles` reply means
581
+ * that worker is wedged inside ONE synchronous WASM call —
582
+ * `geometry.worker.js` serializes every message behind its tail promise, so
583
+ * continuing the fallback without replacing it would queue the fallback's
584
+ * own `stream-start`/chunks/`stream-end` forever behind the stuck call, and
585
+ * `workersCompleted` would never reach `workers.length`. None of the three
586
+ * bounds fires after any `stream-chunk` has reached a worker (they all sit
587
+ * strictly before `dispatchJobsChunk`'s gate can open), so there is no
588
+ * in-flight slice to replay — replaying `workerSetup` alone brings the
589
+ * replacement to the same state every other worker is in.
590
+ */
591
+ function replacePreWorkerPhaseWorker(index, reason) {
592
+ const hung = workers[index];
593
+ const replacement = makeGeometryWorker();
594
+ workers[index] = replacement; // swap first — the stale-worker guard then drops anything the hung one flushes
595
+ terminateWorkerQuietly(hung, `process worker (${reason})`);
596
+ installWorkerHandlers(replacement, index);
597
+ for (const setup of workerSetup)
598
+ setup(replacement);
599
+ if (endSentToWorkers)
600
+ replacement.postMessage({ type: 'stream-end' });
601
+ console.warn(`[stream] worker[${index}] replaced: silent past its #4902 ${reason} bound`);
602
+ }
562
603
  const sendStreamEnd = () => {
563
604
  if (endSentToWorkers)
564
605
  return;
@@ -607,12 +648,13 @@ existingSab, options) {
607
648
  const emptyU32 = new Uint32Array(0);
608
649
  const emptyU8 = new Uint8Array(0);
609
650
  const batchSizing = options?.batchSizing ?? readBatchSizingOverride();
610
- for (const worker of workers) {
651
+ const frameMeta = prepassMeta;
652
+ broadcastSetup((worker) => {
611
653
  worker.postMessage({
612
654
  type: 'stream-start', sourceSessionId,
613
655
  sharedBuffer,
614
- unitScale: prepassMeta.unitScale,
615
- planeAngleToRadians: prepassMeta.planeAngleToRadians,
656
+ unitScale: frameMeta.unitScale,
657
+ planeAngleToRadians: frameMeta.planeAngleToRadians,
616
658
  rtcX, rtcY, rtcZ,
617
659
  needsShift: effectiveNeedsShift,
618
660
  voidKeys: emptyU32,
@@ -622,7 +664,7 @@ existingSab, options) {
622
664
  styleColors: emptyU8,
623
665
  ...(batchSizing ? { batchSizing } : {}),
624
666
  });
625
- }
667
+ });
626
668
  // Don't drain queued chunks here — wait for the `styles` event so
627
669
  // every chunk gets processed with resolved colours. The styles
628
670
  // handler does the drain after posting set-styles.
@@ -649,9 +691,14 @@ existingSab, options) {
649
691
  sub[o + 1] = jobs[src + 1];
650
692
  sub[o + 2] = jobs[src + 2];
651
693
  }
652
- workers[i].postMessage({ type: 'stream-chunk', jobsFlat: sub }, [sub.buffer]);
694
+ postChunk(i, sub);
653
695
  }
654
696
  }
697
+ /** Record a slice in the ledger, then transfer it to worker `i` (#4884). */
698
+ function postChunk(i, jobs, maxBatchJobs) {
699
+ const seq = ledger.recordDispatch(i, jobs, maxBatchJobs);
700
+ workers[i].postMessage({ type: 'stream-chunk', jobsFlat: jobs, seq, ...(maxBatchJobs !== undefined ? { maxBatchJobs } : {}) }, [jobs.buffer]);
701
+ }
655
702
  function dispatchJobsChunkInternal(jobs, affinity) {
656
703
  if (workers.length === 0 || jobs.length === 0)
657
704
  return;
@@ -681,7 +728,7 @@ existingSab, options) {
681
728
  sub[j + 1] = jobs[src + 1];
682
729
  sub[j + 2] = jobs[src + 2];
683
730
  }
684
- workers[i].postMessage({ type: 'stream-chunk', jobsFlat: sub }, [sub.buffer]);
731
+ postChunk(i, sub);
685
732
  }
686
733
  }
687
734
  catch (err) {
@@ -725,6 +772,15 @@ existingSab, options) {
725
772
  streamEndPendingQueueDrain = false;
726
773
  sendStreamEnd();
727
774
  }
775
+ // Every pre-worker gate is open, so no #4902 phase-bound timeout can still
776
+ // need `workerSetup` to replace a worker (each of the three settles at or
777
+ // before this point — see stall-phase.ts). With #4884 in-call recovery
778
+ // OFF, nothing else ever replays it either: release the closures (styles/
779
+ // prepass-columns retain the large prepass arrays) instead of holding them
780
+ // for the rest of the load. Recovery ON still needs the full log to bring
781
+ // a LATER in-call replacement up to date, so it is left alone.
782
+ if (!(hungJobTimeoutMs > 0))
783
+ workerSetup.length = 0;
728
784
  };
729
785
  // Step-by-step timing so we can tell exactly where time goes.
730
786
  const t0 = performance.now();
@@ -771,19 +827,9 @@ existingSab, options) {
771
827
  new Uint32Array(sabStarts).set(starts);
772
828
  new Uint32Array(sabLengths).set(lengths);
773
829
  }
774
- for (const w of workers) {
775
- try {
776
- w.postMessage({
777
- type: 'set-entity-index',
778
- ids: new Uint32Array(sabIds),
779
- starts: new Uint32Array(sabStarts),
780
- lengths: new Uint32Array(sabLengths),
781
- });
782
- }
783
- catch (err) {
784
- console.warn('[stream] set-entity-index dispatch failed:', err);
785
- }
786
- }
830
+ broadcastSetup(entityIndexSetup(() => ({
831
+ ids: new Uint32Array(sabIds), starts: new Uint32Array(sabStarts), lengths: new Uint32Array(sabLengths),
832
+ })));
787
833
  if (options?.onEntityIndex) {
788
834
  try {
789
835
  options.onEntityIndex(new Uint32Array(sabIds), new Uint32Array(sabStarts), new Uint32Array(sabLengths), oversizedIdCount, malformedRecordCount);
@@ -794,17 +840,10 @@ existingSab, options) {
794
840
  }
795
841
  }
796
842
  else {
797
- for (const w of workers) {
798
- try {
799
- w.postMessage({
800
- type: 'set-entity-index',
801
- ids: ids.slice(), starts: starts.slice(), lengths: lengths.slice(),
802
- });
803
- }
804
- catch (err) {
805
- console.warn('[stream] set-entity-index dispatch failed:', err);
806
- }
807
- }
843
+ const copyIds = ids, copyStarts = starts, copyLengths = lengths;
844
+ broadcastSetup(entityIndexSetup(() => ({
845
+ ids: copyIds.slice(), starts: copyStarts.slice(), lengths: copyLengths.slice(),
846
+ })));
808
847
  if (options?.onEntityIndex) {
809
848
  try {
810
849
  options.onEntityIndex(ids.slice(), starts.slice(), lengths.slice(), oversizedIdCount, malformedRecordCount);
@@ -815,6 +854,7 @@ existingSab, options) {
815
854
  }
816
855
  }
817
856
  entityIndexReceived = true;
857
+ gateTracker.markEntityIndexReceived();
818
858
  drainQueuedChunksIfReady();
819
859
  };
820
860
  /**
@@ -826,6 +866,11 @@ existingSab, options) {
826
866
  * the serial pre-pass path — identical to flag-off behaviour.
827
867
  */
828
868
  const onAllShardsReceived = () => {
869
+ // A late straggler shard result may land after the #4902 bound already fell back.
870
+ if (shardScanSettled)
871
+ return;
872
+ shardScanSettled = true;
873
+ gateTracker.markShardScanDone();
829
874
  const shards = shardResults;
830
875
  const stitched = stitchShards(shards);
831
876
  if (!stitched) {
@@ -880,6 +925,24 @@ existingSab, options) {
880
925
  const slice = styledSpans.slice(from, to);
881
926
  workers[i % workers.length].postMessage({ type: 'resolve-styles-shard', sourceSessionId, sharedBuffer, sliceIndex: i, spans: slice }, [slice.buffer]);
882
927
  }
928
+ // #4902 bound: a missing slice at the deadline is treated as empty
929
+ // (`onAllStyleSlicesReceived` already skips a `null` entry) — see stall-phase.ts.
930
+ phaseBoundTimers.arm(fileSizeMB, () => stylesSlicesSettled, () => {
931
+ console.warn(`[stream][shard] ${stylesSlicesRemaining}/${sliceCount} style slice(s) silent — proceeding with the slices that answered (#4902)`);
932
+ diagnostics = mergeGeometryDiagnostics(diagnostics, preWorkerPhaseFailureDiagnostics('style-slice-timeout'));
933
+ // Replace every worker owning a still-silent slice BEFORE continuing —
934
+ // same reasoning as the shard-scan bound above: `resolve-styles-shard`
935
+ // is synchronous WASM work on that worker's own FIFO queue.
936
+ const hungWorkers = new Set();
937
+ for (let i = 0; i < sliceCount; i++) {
938
+ if (!stylesSliceResults[i])
939
+ hungWorkers.add(i % workers.length);
940
+ }
941
+ for (const idx of hungWorkers)
942
+ replacePreWorkerPhaseWorker(idx, 'style-slice-timeout');
943
+ stylesSlicesRemaining = 0;
944
+ onAllStyleSlicesReceived();
945
+ });
883
946
  // Start the sharded pre-pass with the stitched index columns + classes
884
947
  // (stage 2: the pre-pass discovers jobs/spans from the class column and
885
948
  // never byte-scans the file).
@@ -895,6 +958,10 @@ existingSab, options) {
895
958
  * canonical flatten emits the styles event through the same channel.
896
959
  */
897
960
  const onAllStyleSlicesReceived = () => {
961
+ // A late straggler slice may land after the #4902 bound already merged.
962
+ if (stylesSlicesSettled)
963
+ return;
964
+ stylesSlicesSettled = true;
898
965
  const orphan = new Map(); // id -> base float index (slice,i)
899
966
  const geom = new Map();
900
967
  // First pass: count winners to size the merged columns.
@@ -959,6 +1026,18 @@ existingSab, options) {
959
1026
  planeAngleToRadians: prepassMeta.planeAngleToRadians ?? 1,
960
1027
  }, [m.orphanIds.buffer, m.orphanColors.buffer, m.geomIds.buffer, m.geomColors.buffer]);
961
1028
  console.log(`[stream][shard] styles finalize dispatched to worker[0] @ ${elapsed()}ms`);
1029
+ // #4902 bound: replay the empty-styles event (stall-phase.ts) so every
1030
+ // held chunk drains with default colours instead of never draining.
1031
+ phaseBoundTimers.arm(fileSizeMB, () => finalizeSettled, () => {
1032
+ finalizeSettled = true;
1033
+ console.warn('[stream][shard] styles finalize silent — draining with default colours (#4902)');
1034
+ diagnostics = mergeGeometryDiagnostics(diagnostics, preWorkerPhaseFailureDiagnostics('styles-finalize-timeout'));
1035
+ // worker[0] is the sole target of `finalize-styles`; replace it before
1036
+ // the fallback drains chunks, or its still-wedged FIFO queue would
1037
+ // never consume the `stream-chunk`/`stream-end` the drain sends it.
1038
+ replacePreWorkerPhaseWorker(0, 'styles-finalize-timeout');
1039
+ prepassWorker.onmessage({ data: emptyStylesPrepassEvent() });
1040
+ });
962
1041
  };
963
1042
  // SPIKE: kick off the shard scans on the idle workers NOW (before the pre-pass
964
1043
  // worker's single scan). Gated: flag on, SAB available, file big enough, ≥2
@@ -974,6 +1053,7 @@ existingSab, options) {
974
1053
  shardResults.length = n;
975
1054
  shardResultsRemaining = n;
976
1055
  shardScanDispatchedAt = elapsed();
1056
+ gateTracker.markShardScanStarted();
977
1057
  console.log(`[stream][shard] dispatching ${n} shard scans over ${(len / (1024 * 1024)).toFixed(1)}MB @ ${shardScanDispatchedAt}ms`);
978
1058
  for (let i = 0; i < n; i++) {
979
1059
  const rangeStart = Math.floor((i * len) / n);
@@ -986,6 +1066,23 @@ existingSab, options) {
986
1066
  rangeEnd,
987
1067
  });
988
1068
  }
1069
+ // #4902 bound: falls back to the serial pre-pass, same as an unresolved
1070
+ // stitch (`onAllShardsReceived`'s `!stitched` branch) — see stall-phase.ts.
1071
+ phaseBoundTimers.arm(fileSizeMB, () => shardScanSettled, () => {
1072
+ shardScanSettled = true;
1073
+ gateTracker.markShardScanDone();
1074
+ console.warn(`[stream][shard] ${shardResultsRemaining}/${n} shard scan(s) silent — falling back to the serial pre-pass (#4902)`);
1075
+ diagnostics = mergeGeometryDiagnostics(diagnostics, preWorkerPhaseFailureDiagnostics('shard-scan-timeout'));
1076
+ // Replace every worker still silent on scan-shard BEFORE continuing:
1077
+ // `scan-shard` is a synchronous WASM call, so a missing reply means
1078
+ // that worker is wedged, not just slow — and the fallback's own
1079
+ // stream-start/chunks/stream-end would queue forever behind it.
1080
+ for (let i = 0; i < n; i++) {
1081
+ if (!shardResults[i])
1082
+ replacePreWorkerPhaseWorker(i, 'shard-scan-timeout');
1083
+ }
1084
+ startPrepass(false);
1085
+ });
989
1086
  }
990
1087
  const prepassWorker = makePrepassWorker();
991
1088
  // Wrap the rest of the pipeline so worker teardown runs not only on
@@ -994,7 +1091,32 @@ existingSab, options) {
994
1091
  // is suspended at a `yield` or the `resolveWaiting` await. The viewer's
995
1092
  // `watchedGeometryStream` relies on this `finally` to tear down workers
996
1093
  // on break / abort / watchdog (see boundedIteratorReturn). The existing
1094
+ // Abandonment and hung-call recovery (#4884). `return()` cannot reach this
1095
+ // `finally` while the loop is parked on a silent worker, so a stalled load
1096
+ // leaked its pool (hung worker included) into the retry: abort terminates it.
1097
+ let aborted = false;
1098
+ const onAbort = () => {
1099
+ aborted = true;
1100
+ for (const w of workers)
1101
+ terminateWorkerQuietly(w, 'process worker (abort)');
1102
+ terminateWorkerQuietly(prepassWorker, 'pre-pass worker (abort)');
1103
+ wake();
1104
+ };
1105
+ const skippedHungElements = new SkippedHungElementsCollector();
1106
+ let stopHungJobMonitor = () => { };
997
1107
  try {
1108
+ if (options?.signal?.aborted)
1109
+ onAbort();
1110
+ else
1111
+ options?.signal?.addEventListener('abort', onAbort, { once: true });
1112
+ stopHungJobMonitor = startHungJobMonitor({
1113
+ workers, ledger, makeWorker: makeGeometryWorker, installHandlers: installWorkerHandlers,
1114
+ setup: workerSetup, postChunk, streamEndSent: () => endSentToWorkers,
1115
+ terminate: terminateWorkerQuietly, source: new Uint8Array(sharedBuffer), skipped: skippedHungElements,
1116
+ isLive: () => !aborted && !workerError && !prepassError,
1117
+ onLiveness: () => { eventQueue.push({ type: 'progress', phase: 'workers' }); wake(); },
1118
+ onFailed: (error) => { workerError ??= error; wake(); },
1119
+ }, hungJobTimeoutMs);
998
1120
  // Forward the consumer-supplied wasm URL to the pre-pass worker so it
999
1121
  // doesn't fall back to wasm-bindgen's `import.meta.url` default. The
1000
1122
  // pre-pass worker uses the same `geometry.worker.js` bundle and the
@@ -1073,7 +1195,7 @@ existingSab, options) {
1073
1195
  const materialColorCounts = evt.materialColorCounts ?? new Uint32Array(0);
1074
1196
  const materialColors = evt.materialColors ?? new Uint8Array(0);
1075
1197
  console.log(`[stream] styles @ ${elapsed()}ms (${styleIds.length} styled, ${voidKeys.length} void hosts), draining ${queuedChunks.length} queued chunks`);
1076
- for (const w of workers) {
1198
+ broadcastSetup((w) => {
1077
1199
  // Slice each typed array per-worker so each can be in its own
1078
1200
  // transfer list without conflict. The slice cost is bounded by
1079
1201
  // `styleIds.length * 4` bytes — under 1 MB for ~250K styles.
@@ -1104,8 +1226,10 @@ existingSab, options) {
1104
1226
  catch (err) {
1105
1227
  console.warn('[stream] set-styles dispatch failed:', err);
1106
1228
  }
1107
- }
1229
+ });
1108
1230
  stylesReceived = true;
1231
+ finalizeSettled = true; // reached via the real path — the #4902 bound is moot now
1232
+ gateTracker.markStylesReceived();
1109
1233
  // Drain only when ALL gates are open (entity-index too). The
1110
1234
  // worker's tail-promise serialiser ensures any set-* runs
1111
1235
  // before any subsequent stream-chunk.
@@ -1154,7 +1278,7 @@ existingSab, options) {
1154
1278
  const mliLayerMaterialIds = evt.mliLayerMaterialIds;
1155
1279
  const mliLayerThicknesses = evt.mliLayerThicknesses;
1156
1280
  console.log(`[stream] prepass-columns @ ${elapsed()}ms (${referencedRepmaps.length} repmaps, ${instantiatedTypeIds.length} inst-types, ${mliElementIds.length} layer-elems)`);
1157
- for (const w of workers) {
1281
+ broadcastSetup((w) => {
1158
1282
  try {
1159
1283
  const rRepmaps = referencedRepmaps.slice();
1160
1284
  const rTypeIds = instantiatedTypeIds.slice();
@@ -1186,7 +1310,7 @@ existingSab, options) {
1186
1310
  catch (err) {
1187
1311
  console.warn('[stream] set-prepass-columns dispatch failed:', err);
1188
1312
  }
1189
- }
1313
+ });
1190
1314
  // Not a dispatch gate (see dispatchJobsChunk); the pre-pass emits this
1191
1315
  // before the first jobs chunk, so drain here only for symmetry with the
1192
1316
  // other post-scan events in case chunks were queued behind styles/index.
@@ -1252,6 +1376,7 @@ existingSab, options) {
1252
1376
  // After we see the Rust `complete` event we can sendStreamEnd.
1253
1377
  const onPrepassComplete = () => {
1254
1378
  prepassDone = true;
1379
+ gateTracker.markPrepassDone();
1255
1380
  // Only signal stream-end to workers if they actually got
1256
1381
  // stream-start (which gates on `meta`). Zero-geometry files
1257
1382
  // never trigger meta → workers never start → no stream-end
@@ -1342,9 +1467,13 @@ existingSab, options) {
1342
1467
  // b) all workers reported `complete`
1343
1468
  let prepassCompleteSeen = false;
1344
1469
  while (true) {
1470
+ if (aborted)
1471
+ return;
1345
1472
  while (eventQueue.length > 0) {
1346
1473
  yield eventQueue.shift();
1347
1474
  }
1475
+ if (aborted)
1476
+ return;
1348
1477
  if (workerError) {
1349
1478
  for (const w of workers) {
1350
1479
  terminateWorkerQuietly(w, 'process worker');
@@ -1391,14 +1520,26 @@ existingSab, options) {
1391
1520
  `${loadDiagnostics.productsWithFailures} product(s) this load - see ` +
1392
1521
  `diagnostics.failuresByReason; not every reason leaves an opening/void uncut`);
1393
1522
  }
1523
+ const skippedReport = skippedHungElements.report();
1394
1524
  yield {
1395
1525
  type: 'complete',
1396
1526
  totalMeshes,
1397
1527
  coordinateInfo,
1398
1528
  ...(loadDiagnostics ? { diagnostics: loadDiagnostics } : {}),
1529
+ ...(skippedReport ? { skippedHungElements: skippedReport } : {}),
1399
1530
  };
1400
1531
  }
1401
1532
  finally {
1533
+ phaseBoundTimers.clearAll();
1534
+ // Re-seat the caller's handle to a frozen snapshot (#4979 review): the
1535
+ // live reader closes over `gateTracker`, and transitively this whole
1536
+ // generator's scope (including `sharedBuffer`) — the caller may still
1537
+ // hold the handle long after this teardown runs.
1538
+ if (options?.stallPhaseHandle) {
1539
+ options.stallPhaseHandle.getStallPhase = frozenStallPhase(gateTracker.getStallPhase());
1540
+ }
1541
+ stopHungJobMonitor();
1542
+ options?.signal?.removeEventListener('abort', onAbort);
1402
1543
  for (const w of workers) {
1403
1544
  terminateWorkerQuietly(w, 'process worker');
1404
1545
  }