Skip to content

Commit 6094e2f

Browse files
authored
fix(parser): prevent Firefox spatial-metadata publication stalls (#3984)
* fix(parser): avoid Firefox stalls when spatial metadata reaches the viewer * chore(viewer): remove unused hierarchy subscriptions * chore(lint): tighten unused hierarchy selector budgets * fix(viewer): distinguish completed geometry-only hierarchy state * chore(parser): clear touched-module lint warnings
1 parent 0ea7262 commit 6094e2f

14 files changed

Lines changed: 180 additions & 55 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ifc-lite/parser": patch
3+
---
4+
5+
Avoid Firefox stalls while publishing large-model metadata by keeping entity-cache eviction linear across scans and preparing georeferencing and source fingerprints in the parser worker.

apps/viewer/src/components/viewer/HierarchyPanel.federation.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ describe('HierarchyPanel — federated unified-storey selection', () => {
127127
resetStore();
128128
});
129129

130+
it('distinguishes pending IFC metadata from completed geometry-only models (#3984)', () => {
131+
const model = federatedModel('m1', makeStore(5, 0, 'Level 1'));
132+
model.ifcDataStore = null;
133+
model.loadState = 'hydrating-metadata';
134+
useViewerStore.setState({ models: new Map([['m1', model]]) });
135+
const container = renderPanel();
136+
assert.match(container.textContent ?? '', /Building the hierarchy/);
137+
act(() => {
138+
useViewerStore.setState({ models: new Map([['m1', { ...model, loadState: 'complete' }]]) });
139+
});
140+
assert.match(container.textContent ?? '', /No hierarchy available for this model/);
141+
assert.doesNotMatch(container.textContent ?? '', /Building the hierarchy/);
142+
});
143+
130144
it('selecting Level 1 (model m1, local id 5) does not cross-highlight Level 2 (model m2, same local id 5)', () => {
131145
const m1 = federatedModel('m1', makeStore(5, 0, 'Level 1'));
132146
const m2 = federatedModel('m2', makeStore(5, 10, 'Level 2'));

apps/viewer/src/components/viewer/HierarchyPanel.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export function HierarchyPanel() {
5858
const setSelectedModelId = useViewerStore((s) => s.setSelectedModelId);
5959
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
6060
const activeStorey = useViewerStore((s) => s.activeStorey);
61-
const setStoreySelection = useViewerStore((s) => s.setStoreySelection);
6261
const setStoreysSelection = useViewerStore((s) => s.setStoreysSelection);
6362
const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
6463
const setActiveStorey = useViewerStore((s) => s.setActiveStorey);
@@ -87,7 +86,6 @@ export function HierarchyPanel() {
8786
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
8887
const hideEntities = useViewerStore((s) => s.hideEntities);
8988
const showEntities = useViewerStore((s) => s.showEntities);
90-
const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
9189
const clearSelection = useViewerStore((s) => s.clearSelection);
9290

9391
// Derive label for type isolation (from the Type tab, or any other
@@ -802,10 +800,9 @@ export function HierarchyPanel() {
802800
if (!ifcDataStore && singleModel) {
803801
const metadataState = singleModel.metadataLoadState;
804802
const message = metadataState === 'error'
805-
? (singleModel.loadError || 'Native metadata failed to load.')
806-
: metadataState === 'bootstrapping'
807-
? 'Native spatial metadata is loading.'
808-
: 'Spatial metadata will appear once bootstrap completes.';
803+
? (singleModel.loadError || 'Model details failed to load.')
804+
: singleModel.loadState === 'complete' ? 'No hierarchy available for this model.'
805+
: 'Building the hierarchy. You can explore the geometry while model details load.';
809806
return (
810807
<div className="h-full flex flex-col border-r-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
811808
<div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">

apps/viewer/src/hooks/useIfcLoader.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,10 +1354,8 @@ export function useIfcLoader() {
13541354
// the geometry workers read the same memory zero-copy. When
13551355
// `acquireFileBuffer` already streamed the file directly into a SAB
13561356
// (large-file entry path, issue #600), reuse it — no second copy.
1357-
// `WorkerParser.isSupported()` rolls together: COI enabled, SAB
1358-
// available, AND TextDecoder accepts SAB-backed views (Firefox fails
1359-
// the third check; we skip the worker path entirely there so the
1360-
// SAB allocation isn't wasted).
1357+
// `WorkerParser.isSupported()` checks COI, SAB and Worker availability.
1358+
// The parser's UTF-8 reader handles SAB-backed views in Firefox too.
13611359
const useParserWorker = WorkerParser.isSupported();
13621360
let sharedSource: SharedArrayBuffer | null = null;
13631361
if (useParserWorker) {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
import { describe, expect, it } from 'vitest';
5+
import { CompactEntityIndex } from './compact-entity-index.js';
6+
7+
function index(capacity: number, count = 20_000): CompactEntityIndex {
8+
const ids = Uint32Array.from({ length: count }, (_, i) => i + 1);
9+
return new CompactEntityIndex(ids, ids.map(id => id * 80), new Uint32Array(count).fill(80),
10+
new Uint16Array(count), ['IFCWALL'], capacity);
11+
}
12+
13+
describe('entity cache eviction after long scans (#3983)', () => {
14+
it('preserves reference contents and hot entries across eviction and clearing', () => {
15+
const data = index(3);
16+
const hot = data.get(1);
17+
data.get(2);
18+
const cold = data.get(3);
19+
for (let id = 4; id <= 20_000; id++) {
20+
expect(data.get(1)).toBe(hot);
21+
expect(data.get(id)).toEqual({ expressId: id, type: 'IFCWALL', byteOffset: id * 80, byteLength: 80, lineNumber: 0 });
22+
}
23+
expect(data.get(3)).not.toBe(cold);
24+
data.clearCache();
25+
const renewed = data.get(1);
26+
expect(renewed).toEqual(hot);
27+
expect(renewed).not.toBe(hot);
28+
for (let id = 4; id < 100; id++) { data.get(1); data.get(id); }
29+
expect(data.get(1)).toBe(renewed);
30+
expect(data.get(99)?.byteOffset).toBe(7920);
31+
});
32+
33+
it('supports zero-capacity caches and invalid lookups without exhausting eviction', () => {
34+
const data = index(0, 100);
35+
for (let id = 1; id <= 100; id++) {
36+
const first = data.get(id);
37+
expect(data.get(id)).toEqual(first);
38+
expect(data.get(id)).not.toBe(first);
39+
expect(data.get(-1)).toBeUndefined();
40+
}
41+
});
42+
});

packages/parser/src/compact-entity-index.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,8 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
44

5-
/**
6-
* CompactEntityIndex - Memory-efficient entity index using typed arrays
7-
*
8-
* Replaces Map<number, EntityRef> with sorted typed arrays for O(log n) lookup.
9-
* For 8.4M entities, this saves ~400MB of Map overhead:
10-
* - Map: ~56 bytes/entry overhead (key + value + hash table) = ~470MB
11-
* - Typed arrays: ~16 bytes/entry (4 Uint32Arrays) = ~134MB
12-
*
13-
* Provides the same Map-like interface via get()/has() for drop-in compatibility.
14-
*/
5+
/** Sorted entity columns avoid the per-entry overhead of Map<number, EntityRef>.
6+
* Lookups use binary search with a bounded LRU for recently read references. */
157

168
import { checkedExpressId } from './express-id.js';
179
import type { EntityRef } from './types.js';
@@ -51,6 +43,7 @@ export class CompactEntityIndex {
5143

5244
/** LRU cache for recently accessed EntityRefs */
5345
private lruCache: Map<number, EntityRef>;
46+
private lruKeys: MapIterator<number> | undefined;
5447
private readonly lruMaxSize: number;
5548

5649
constructor(
@@ -125,8 +118,10 @@ export class CompactEntityIndex {
125118
// Add to LRU cache
126119
this.lruCache.set(expressId, ref);
127120
if (this.lruCache.size > this.lruMaxSize) {
128-
// Delete oldest entry (first key in insertion order)
129-
const firstKey = this.lruCache.keys().next().value;
121+
// #3983: keep a live cursor. Restarting at the Map's deleted prefix for
122+
// every eviction makes a large sequential scan quadratic in Firefox.
123+
this.lruKeys ??= this.lruCache.keys();
124+
const firstKey = this.lruKeys.next().value;
130125
if (firstKey !== undefined) {
131126
this.lruCache.delete(firstKey);
132127
}
@@ -234,6 +229,7 @@ export class CompactEntityIndex {
234229
*/
235230
clearCache(): void {
236231
this.lruCache.clear();
232+
this.lruKeys = undefined;
237233
}
238234

239235
/**

packages/parser/src/data-store-transport.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,9 @@
33
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
44

55
/**
6-
* Worker-boundary transport for `IfcDataStore`.
7-
*
8-
* `IfcDataStore` carries closures (`entities.getName`, `relationships.getRelated`,
9-
* `spatialHierarchy.getPath`, …) that the structured-clone algorithm strips
10-
* silently. This module separates the clone-safe column data from the
11-
* closures: `toTransport` returns a POJO + transferable list to ship across
12-
* a `postMessage` boundary; `fromTransport` reconstructs a live `IfcDataStore`
13-
* with closures rebuilt on the receiving thread.
14-
*
15-
* The `source` buffer is intentionally NOT included in the transferable list
16-
* because both the parser worker and the geometry workers read from the same
17-
* `SharedArrayBuffer` upstream of the parser. Callers are responsible for
18-
* keeping a `Uint8Array` view of that SAB on the main thread and supplying
19-
* it to `fromTransport`.
6+
* Worker transport splits clone-safe columns from live store accessors.
7+
* toTransport serializes columns; fromTransport rebuilds their closures.
8+
* Source bytes stay in the shared buffer, supplied separately by the receiver.
209
*/
2110

2211
import {
@@ -54,6 +43,8 @@ import type { EntityRef } from './types.js';
5443
import { asSourceBytes, type IfcSourceBytes } from './source-bytes.js';
5544
import type { IfcDataStore, EntityByIdIndex } from './columnar-parser.js';
5645
import { attachDataStoreAccessors } from './data-store-accessors.js';
46+
import type { GeoreferenceInfo } from './georef-extractor.js';
47+
import { oncePerStore } from './on-demand-cache.js';
5748

5849
export type { CompactEntityIndexColumns };
5950

@@ -213,6 +204,9 @@ export interface ParserMemorySnapshot {
213204
// ────────────────────────────────────────────────────────────────────────────
214205

215206
export interface DataStoreTransport {
207+
/** Worker-prepared render data (#3983); absent on older transports. */
208+
sourceContentKey?: string | null;
209+
georeferencing?: GeoreferenceInfo | null;
216210
fileSize: number;
217211
schemaVersion: IfcDataStore['schemaVersion'];
218212
sourceHeader?: IfcDataStore['sourceHeader'];
@@ -447,7 +441,7 @@ export function fromTransport(
447441
const onDemandQuantityMap = new Map(payload.onDemandQuantityMap.map(([k, v]) => [k, [...v]]));
448442
// Lazy accessors are wired by the shared helper so the fresh-parse, transport,
449443
// and cache-restore paths can never drift (see data-store-accessors.ts).
450-
return attachDataStoreAccessors({
444+
const store = attachDataStoreAccessors({
451445
fileSize: payload.fileSize,
452446
schemaVersion: payload.schemaVersion,
453447
sourceHeader: payload.sourceHeader,
@@ -472,6 +466,10 @@ export function fromTransport(
472466
onDemandMaterialMap: new Map(payload.onDemandMaterialMap),
473467
onDemandDocumentMap: new Map(payload.onDemandDocumentMap.map(([k, v]) => [k, [...v]])),
474468
});
469+
if (payload.georeferencing !== undefined) {
470+
oncePerStore(store, 'georef', () => payload.georeferencing);
471+
}
472+
return store;
475473
}
476474

477475
/**

packages/parser/src/parser-worker-malformed-handoff.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
* per-test and why the hook timeout is generous.
2424
*/
2525

26-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
26+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
27+
28+
import { fromTransport, type DataStoreTransport } from './data-store-transport.js';
29+
import { extractGeoreferencingOnDemand } from './on-demand-georeferencing.js';
30+
import { contiguousSourceBytes } from './source-bytes.js';
2731

2832
const postedMessages: unknown[] = [];
2933
let originalSelf: unknown;
@@ -44,6 +48,7 @@ const IFC = [
4448
'DATA;',
4549
"#1=IFCPROJECT('0000000000000000000001',$,'P',$,$,$,$,$,$);",
4650
"#2=IFCWALL('0000000000000000000002',$,'Wall2',$,$,$,$,$,$);",
51+
"#3=IFCSITE('0000000000000000000003',$,'Site',$,$,$,$,$,.ELEMENT.,(47,0,0),(8,0,0),0.,$,$);",
4752
'ENDSEC;',
4853
'END-ISO-10303-21;',
4954
'',
@@ -120,7 +125,8 @@ beforeEach(async () => {
120125
originalPostMessage = g.postMessage;
121126
g.self = globalThis;
122127
g.postMessage = (msg: unknown) => postedMessages.push(msg);
123-
await import('./parser.worker.js?t=' + ++importCounter);
128+
importCounter += 1;
129+
await import('./parser.worker.js?t=' + importCounter);
124130
}, WORKER_IMPORT_HOOK_TIMEOUT_MS);
125131

126132
afterEach(() => {
@@ -176,3 +182,34 @@ describe('parser.worker.ts and the #3790 set-entity-index handoff', () => {
176182
expect(diagnostics().some((m) => m.includes('stopped early'))).toBe(false);
177183
}, 30_000);
178184
});
185+
186+
// #3983: execute the real worker handler; verify the receiver needs no entity
187+
// lookups for its first georeference read (including the early spatial store).
188+
it('prepares render metadata before publishing partial and complete stores (#3983)', async () => {
189+
const records = [...IFC.matchAll(/#(\d+)=[^;]+;/g)];
190+
post({ type: 'set-entity-index',
191+
ids: Uint32Array.from(records, r => Number(r[1])),
192+
starts: Uint32Array.from(records, r => r.index!),
193+
lengths: Uint32Array.from(records, r => r[0].length),
194+
});
195+
startParse();
196+
await settle();
197+
assertParsed();
198+
const messages = postedMessages.filter((m): m is { type: string; payload: DataStoreTransport } =>
199+
['partial-store', 'complete'].includes((m as { type: string }).type));
200+
expect(messages).toHaveLength(2);
201+
for (const { payload } of messages) {
202+
const source = contiguousSourceBytes(new Uint8Array(sharedSource()), payload.sourceContentKey ?? undefined);
203+
// Full-fixture FNV-1a, independently calculated with Python integer arithmetic.
204+
expect(payload.sourceContentKey).toBe('16b-6d79a917');
205+
// toTransferable does not compute a key: this proves it arrived pre-seeded.
206+
expect(source.toTransferable().contentKey).toBe(payload.sourceContentKey);
207+
const store = fromTransport(structuredClone(payload), source);
208+
const lookups = vi.spyOn(store.entityIndex.byId, 'get');
209+
const georef = extractGeoreferencingOnDemand(store);
210+
expect(georef?.source).toBe('siteLocation');
211+
expect(georef?.projectedCRS?.name).toBe('EPSG:4326');
212+
expect(lookups).not.toHaveBeenCalled();
213+
lookups.mockRestore();
214+
}
215+
}, 30_000);

packages/parser/src/parser.worker.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818
import init, { IfcAPI } from '@ifc-lite/wasm';
1919
import { initWasmWithRetry } from './wasm-init-retry.js';
2020
import { IfcParser } from './index.js';
21+
import { extractGeoreferencingOnDemand } from './on-demand-georeferencing.js';
2122
import type { IfcDataStore } from './columnar-parser.js';
2223
import type { WasmScanApi } from './entity-scanner.js';
2324
import {
24-
collectTransferables,
2525
toTransport,
2626
transportByteSize,
2727
type DataStoreTransport,
@@ -244,9 +244,8 @@ self.onmessage = async (event: MessageEvent<ParserInbound>) => {
244244
try {
245245
// The SAB itself is shared by reference — both this worker and the
246246
// main thread (and the geometry workers) hold views of the same bytes.
247-
// We never transfer or clone it. Runtimes that reject TextDecoder over
248-
// SAB views (e.g. Firefox's timing-attack mitigation) are filtered out
249-
// by the wrapper before this worker is even spawned.
247+
// We never transfer or clone it. The parser's UTF-8 reader also supports
248+
// runtimes that reject TextDecoder over SAB-backed views.
250249
//
251250
// Initialise the WASM scanner. `parseColumnar` prefers the WASM scan when
252251
// `wasmApi` is supplied (5–10× faster on huge files — a 14 M-entity, 986 MB
@@ -293,6 +292,7 @@ self.onmessage = async (event: MessageEvent<ParserInbound>) => {
293292
// index were somehow empty, the scanner falls through to the JS tokeniser.
294293
const wasmApi = wasmApiPromise ? await wasmApiPromise : undefined;
295294
const parser = new IfcParser();
295+
let georeferencing: ReturnType<typeof extractGeoreferencingOnDemand> | undefined;
296296
// `source` is the SAB-backed payload — `parseColumnar` accepts
297297
// `ArrayBuffer | SharedArrayBuffer` so no cast is needed.
298298
const dataStore: IfcDataStore = await parser.parseColumnar(source, {
@@ -311,6 +311,13 @@ self.onmessage = async (event: MessageEvent<ParserInbound>) => {
311311
onSpatialReady: (partialStore) => {
312312
try {
313313
const { payload } = toTransport(partialStore);
314+
// #3983: overlays and Cesium availability read these during React
315+
// rendering. Do the full-source/hash and property-set walks here.
316+
payload.sourceContentKey = partialStore.source.contentKey;
317+
if (!deferPropertyAtomIndex) {
318+
georeferencing = extractGeoreferencingOnDemand(partialStore);
319+
payload.georeferencing = georeferencing;
320+
}
314321
// We intentionally do NOT transfer the partial typed-array
315322
// buffers. The worker keeps using them for the rest of the parse
316323
// (entityIndex.byId.get(...) etc. all read from these arrays).
@@ -327,6 +334,9 @@ self.onmessage = async (event: MessageEvent<ParserInbound>) => {
327334
},
328335
});
329336
const { payload, transfers } = toTransport(dataStore);
337+
payload.sourceContentKey = dataStore.source.contentKey;
338+
payload.georeferencing = georeferencing === undefined
339+
? extractGeoreferencingOnDemand(dataStore) : georeferencing;
330340
// CRITICAL: every field here MUST be synchronous. Do NOT await on this path —
331341
// it gates the 'complete' message (the full data store) reaching the main thread.
332342
// This previously `await`ed performance.measureUserAgentSpecificMemory(); in a

packages/parser/src/worker-parser.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
type DataStoreTransport,
2727
type ParserMemorySnapshot,
2828
} from './data-store-transport.js';
29-
import { contiguousSourceBytes } from './source-bytes.js';
29+
import { contiguousSourceBytes, type IfcSourceBytes } from './source-bytes.js';
3030
import type {
3131
ParserWorkerInputMessage,
3232
ParserWorkerOutputMessage,
@@ -119,7 +119,13 @@ export class WorkerParser {
119119
// on exactly the models #2183 is about. The previous code got one hash by
120120
// memoising on the shared Uint8Array; sharing the accessor is the same
121121
// guarantee without the side table.
122-
const sourceBytes = contiguousSourceBytes(new Uint8Array(source));
122+
let sourceBytes: IfcSourceBytes | undefined;
123+
const hydrate = (payload: DataStoreTransport) => {
124+
// #3983: the worker hashes the source before the first UI publication.
125+
// Retain one accessor across partial/full stores and compression swaps.
126+
sourceBytes ??= contiguousSourceBytes(new Uint8Array(source), payload.sourceContentKey ?? undefined);
127+
return fromTransport(payload, sourceBytes);
128+
};
123129

124130
const settle = (cleanup: () => void) => {
125131
worker.onmessage = null;
@@ -144,7 +150,7 @@ export class WorkerParser {
144150
case 'partial-store': {
145151
if (!options.onSpatialReady) return;
146152
try {
147-
const partial = fromTransport(msg.payload as DataStoreTransport, sourceBytes);
153+
const partial = hydrate(msg.payload);
148154
options.onSpatialReady(partial);
149155
} catch (err) {
150156
// Don't fail the whole parse on partial deserialization
@@ -156,7 +162,7 @@ export class WorkerParser {
156162

157163
case 'complete': {
158164
try {
159-
const dataStore = fromTransport(msg.payload as DataStoreTransport, sourceBytes);
165+
const dataStore = hydrate(msg.payload);
160166
options.onMemorySnapshot?.(msg.memory);
161167
settle(() => {
162168
worker.terminate();

0 commit comments

Comments
 (0)