Versions
@thatopen/components 3.4.8 (verified against the published dist/index.mjs; source on main: packages/core/src/fragments/EdgeProjector/index.ts, packages/core/src/fragments/EdgeProjector/projection/ProjectionGenerator.js).
What happens
EdgeProjector.get() cannot be cancelled, and if any of its three internal waits fails, the returned promise never settles while the generator keeps being pumped on every animation frame — burning CPU indefinitely, with no error, no timeout, and no way out short of tearing down the renderer or the page.
Why
1. The cancellation feature exists one layer down and is not exposed.
ProjectionGenerator.generateAsync already implements it:
// projection/ProjectionGenerator.js
generateAsync( geometry, options = {} ) {
return new Promise( ( resolve, reject ) => {
const { signal } = options;
const task = this.generate( geometry, options );
run();
function run() {
if ( signal && signal.aborted ) {
reject( new Error( 'ProjectionGenerator: Process aborted via AbortSignal.' ) );
return;
}
const result = task.next();
if ( result.done ) { resolve( result.value ); }
else { requestAnimationFrame( run ); }
}
} );
}
EdgeProjector.get builds the options object by hand and omits exactly that key — and its own config parameter has no place to put one:
// EdgeProjector/index.ts
async get(modelIdMap: ModelIdMap, world: World,
config?: { onProgress?: (message: string, progress?: number) => void }) {
…
const collector = await this.generator.generateAsync(group, {
visibilityCuller,
groupFn: (mesh: THREE.Mesh) => { … },
onProgress: config?.onProgress,
// no signal
});
2. Three wait paths, none with a .catch and none with a deadline.
Visibility culling — this one is on the plain WebGL path and is not avoided by setting useWebGPU = false:
// projection/ProjectionGenerator.js — generate()
let finished = false;
visibilityCuller.cull( scene ).then( res => {
scene = new Scene();
scene.children = res;
finished = true;
} );
while ( ! finished ) { yield; }
If cull rejects — lost context, a failed readRenderTargetPixelsAsync, an exception in the tile loop — finished stays false and the while yields forever. cull also acquires renderer state (setRenderTarget, setClearColor, autoClear) before its awaits and restores it at the end without a try/finally, so a rejection additionally leaves the caller's renderer bound to a foreign target.
WebGPU edge cast — same shape, in ProjectedEdgeCollector.addEdgesGenerator:
let webgpuFinished = false;
getBvhcastEdgesWebgpu( webgpuData, meshes, edgesBvh, hiddenOverlapMap, bvhStats ).then( () => {
webgpuFinished = true;
} );
while ( ! webgpuFinished ) { yield; }
A WebGPURenderer that fails to initialise leaves this pending. useWebGPU defaults to true on the generator, and EdgeProjector's constructor overrides only includeIntersectionEdges — so this is the default path. The switch is reachable only through readonly generator: any, i.e. untyped: projector.generator.useWebGpu = false (note the typo) silently does nothing.
The pump itself — generateAsync advances the generator only from requestAnimationFrame. In a context that does not composite (hidden window, background tab, offscreen setup) it makes no progress at all, and again there is no timeout that turns "no frames" into an error.
Reproduction sketch
const projector = components.get(OBC.EdgeProjector);
// (a) There is no way to write this — `config` has no `signal`:
const controller = new AbortController();
await projector.get(modelIdMap, world, { signal: controller.signal }); // ignored
controller.abort(); // no effect
// (b) Make one of the waits fail and observe that nothing settles:
const gl = world.renderer.three.getContext();
gl.getExtension("WEBGL_lose_context").loseContext();
const p = projector.get(modelIdMap, world); // neither resolves nor rejects
// rAF keeps calling run(); the generator keeps yielding on `while (!finished)`
// (c) Run the same call from a page that never composites (hidden window / background tab):
// `onProgress` never fires and the promise never settles.
Suggested fix
-
Forward the signal. Add signal?: AbortSignal to EdgeProjector.get's config and pass it into generateAsync — the machinery already exists, this is one property.
-
Attach a .catch to both .then chains and make the failure escape the generator instead of leaving the flag false. E.g. capture the error next to the finished flag and throw it from the while loop, so generateAsync rejects:
let finished = false, error = null;
visibilityCuller.cull( scene ).then( res => { …; finished = true; }, err => { error = err; } );
while ( ! finished ) { if ( error ) throw error; yield; }
-
Wrap VisibilityCuller.cull's renderer-state changes in try/finally, so a rejection cannot leave the caller's renderer with a foreign render target and clear colour.
-
Optionally, a wall-clock ceiling on each wait so a condition that can never complete fails loudly instead of spinning.
Related: #763 (EdgeProjector returning empty geometry for small subsets — also downstream of the culler stage).
Versions
@thatopen/components3.4.8 (verified against the publisheddist/index.mjs; source onmain:packages/core/src/fragments/EdgeProjector/index.ts,packages/core/src/fragments/EdgeProjector/projection/ProjectionGenerator.js).What happens
EdgeProjector.get()cannot be cancelled, and if any of its three internal waits fails, the returned promise never settles while the generator keeps being pumped on every animation frame — burning CPU indefinitely, with no error, no timeout, and no way out short of tearing down the renderer or the page.Why
1. The cancellation feature exists one layer down and is not exposed.
ProjectionGenerator.generateAsyncalready implements it:EdgeProjector.getbuilds the options object by hand and omits exactly that key — and its ownconfigparameter has no place to put one:2. Three wait paths, none with a
.catchand none with a deadline.Visibility culling — this one is on the plain WebGL path and is not avoided by setting
useWebGPU = false:If
cullrejects — lost context, a failedreadRenderTargetPixelsAsync, an exception in the tile loop —finishedstaysfalseand thewhileyields forever.cullalso acquires renderer state (setRenderTarget,setClearColor,autoClear) before its awaits and restores it at the end without atry/finally, so a rejection additionally leaves the caller's renderer bound to a foreign target.WebGPU edge cast — same shape, in
ProjectedEdgeCollector.addEdgesGenerator:A
WebGPURendererthat fails to initialise leaves this pending.useWebGPUdefaults totrueon the generator, andEdgeProjector's constructor overrides onlyincludeIntersectionEdges— so this is the default path. The switch is reachable only throughreadonly generator: any, i.e. untyped:projector.generator.useWebGpu = false(note the typo) silently does nothing.The pump itself —
generateAsyncadvances the generator only fromrequestAnimationFrame. In a context that does not composite (hidden window, background tab, offscreen setup) it makes no progress at all, and again there is no timeout that turns "no frames" into an error.Reproduction sketch
Suggested fix
Forward the signal. Add
signal?: AbortSignaltoEdgeProjector.get'sconfigand pass it intogenerateAsync— the machinery already exists, this is one property.Attach a
.catchto both.thenchains and make the failure escape the generator instead of leaving the flagfalse. E.g. capture the error next to thefinishedflag andthrowit from thewhileloop, sogenerateAsyncrejects:Wrap
VisibilityCuller.cull's renderer-state changes intry/finally, so a rejection cannot leave the caller's renderer with a foreign render target and clear colour.Optionally, a wall-clock ceiling on each wait so a condition that can never complete fails loudly instead of spinning.
Related: #763 (EdgeProjector returning empty geometry for small subsets — also downstream of the culler stage).