Skip to content

EdgeProjector.get: the AbortSignal that ProjectionGenerator.generateAsync already supports is never passed through, and none of the three wait paths has a catch or timeout — a failure becomes an unstoppable infinite yield #786

Description

@stokuewe

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 itselfgenerateAsync 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions