Skip to content

Second prove() hangs indefinitely in web worker when FinalizationRegistry is active #2870

Description

@mellowcroc

Summary

When running SmartContract.compile()prove()prove() in a web worker with FinalizationRegistry active and the compile cache populated, the second prove() call hangs indefinitely. The first prove() succeeds. Fresh compilation (cache miss) does not exhibit the hang. Replacing FinalizationRegistry with a no-op before o1js loads avoids it.

Environment

  • o1js branch: mesa-srs-fix (commit e0a1022)
  • Runtime: web worker with SharedArrayBuffer (COOP/COEP headers)
  • Browser: Chrome 130+
  • weak-refs feature: enabled in the wasm-bindgen build (confirms FinalizationRegistry is active)

Root cause analysis

The web worker's overrideBindings() in web-backend.js proxies WASM calls to a dedicated worker thread. Return values are wrapped on the main thread via Class.__wrap(ptr), which registers them with wasm-bindgen's per-class FinalizationRegistry. There are 114 such registries in plonk_wasm.js.

The fundamental problem: multiple JS wrappers are created for the same underlying WASM pointer. Each wrapper independently registers with a FinalizationRegistry. When GC collects any one of them, the destructor frees the WASM allocation — even though other wrappers (and the Rust prover) still hold the same pointer.

This manifests specifically with the compile cache because decodeProverKey calls WASM decode functions (e.g. caml_pasta_fp_plonk_index_decode) which go through overrideBindings, producing registered wrappers. Fresh compilation keeps everything on the WASM/OCaml side where JS GC can't reach it.

Additionally, the worker calls __destroy_into_raw() before sending pointers back to the main thread — transferring ownership. For singleton resources like the SRS, this means the worker gives up ownership each time, but the same allocation is returned on subsequent calls. The main thread creates a new wrapper each time, and when any previous wrapper is GC'd, the underlying singleton is freed.

Reproduction

  1. Load app in web worker with COOP/COEP (FinalizationRegistry shim disabled)
  2. compile() with warm cache (prover keys loaded via decodeProverKey)
  3. prove() Building Snarkette #1 succeeds
  4. prove() Replaces this repo with new-snarkyjs from monorepo #2 hangs indefinitely

Without cache (fresh compile), both proves succeed.

Working workaround

Replace FinalizationRegistry with a no-op before o1js loads:

(globalThis as any).FinalizationRegistry = class {
  register() {}
  unregister() { return false; }
};

WASM objects are never explicitly freed; memory is reclaimed when the worker context is destroyed.

Reference implementation: zksecurity/mina-guard ui/lib/disable-wasm-finalizers.ts

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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