From 5a6ae4dcf5a35daec04e5e6e209e891f8f5c08be Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 10:25:57 +0000 Subject: [PATCH 01/19] fix(bb-prover): evict dead bb instances from the BBJsFactory pool A pooled bb instance whose process died was returned to the pool and handed out again, so every later borrow of it failed. The pool now checks BBJsApi.isAlive(): a dead instance is destroyed when it is returned or found idle, and a replacement is spawned when a borrower finds no idle instance while the pool is below its size. A failed spawn fails that borrow and is retried by the next one, and a failed pool initialization is retried too. Requires a bb.js release with Barretenberg.isAlive(). --- .../bb-prover/src/bb/bb_js_backend.test.ts | 84 ++++++++++++- .../bb-prover/src/bb/bb_js_backend.ts | 118 ++++++++++++++---- yarn-project/bb-prover/src/bb/bb_js_debug.ts | 4 + yarn-project/bb-prover/src/test/fake_bb_js.ts | 107 ++++++++++++++++ 4 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 yarn-project/bb-prover/src/test/fake_bb_js.ts diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index c60fc88b29c..ade57e7ce57 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -1,6 +1,7 @@ import { ProvingError } from '@aztec-labs/stdlib/errors'; -import { BBJsInstance } from './bb_js_backend.js'; +import { FakeBBJsFactory } from '../test/fake_bb_js.js'; +import { type BBJsApi, BBJsInstance } from './bb_js_backend.js'; describe('BBJsInstance', () => { it('wraps bb startup failures as a retryable ProvingError', async () => { @@ -9,3 +10,84 @@ describe('BBJsInstance', () => { expect(err.retry).toBe(true); }); }); + +describe('BBJsFactory pool', () => { + let factory: FakeBBJsFactory; + + const verify = (instance: BBJsApi) => instance.verifyChonkProof([], new Uint8Array()); + const verified = { verified: true, durationMs: 1 }; + + afterEach(async () => { + await factory.destroy(); + }); + + it('evicts a borrowed instance whose bb died instead of returning it to the pool', async () => { + factory = new FakeBBJsFactory(1); + { + await using first = await factory.getInstance(); + factory.created[0].kill(); + await expect(verify(first)).rejects.toThrow('Socket not connected'); + } + + await using second = await factory.getInstance(); + await expect(verify(second)).resolves.toEqual(verified); + expect(factory.created).toHaveLength(2); + expect(factory.created[0].destroyCount).toBe(1); + expect(factory.created[1].chonkVerifyCalls).toBe(1); + }); + + it('skips an idle instance whose bb died and spawns a replacement', async () => { + factory = new FakeBBJsFactory(2); + { + await using _warmup = await factory.getInstance(); + } + const [warm, other] = [factory.created[0], factory.created[1]]; + warm.kill(); + + await using a = await factory.getInstance(); + await using b = await factory.getInstance(); + await expect(verify(a)).resolves.toEqual(verified); + await expect(verify(b)).resolves.toEqual(verified); + expect(factory.created).toHaveLength(3); + expect(warm.chonkVerifyCalls).toBe(0); + expect(warm.destroyCount).toBe(1); + expect(other.chonkVerifyCalls).toBe(1); + }); + + it('loses one call to a bb that dies, not a share of the calls after it', async () => { + factory = new FakeBBJsFactory(2); + factory.planNextInstance(['die']); + let failures = 0; + for (let i = 0; i < 40; i++) { + await using instance = await factory.getInstance(); + try { + await verify(instance); + } catch { + failures++; + } + } + expect(failures).toBe(1); + }); + + it('fails the borrow when a replacement cannot be spawned, and spawns it on the next borrow', async () => { + factory = new FakeBBJsFactory(1); + { + await using first = await factory.getInstance(); + factory.created[0].kill(); + } + factory.planNextInstance(new Error('spawn failed')); + + await expect(factory.getInstance()).rejects.toThrow('spawn failed'); + await using second = await factory.getInstance(); + await expect(verify(second)).resolves.toEqual(verified); + }); + + it('retries pool initialization after a failed spawn', async () => { + factory = new FakeBBJsFactory(1); + factory.planNextInstance(new Error('spawn failed')); + + await expect(factory.getInstance()).rejects.toThrow('spawn failed'); + await using instance = await factory.getInstance(); + await expect(verify(instance)).resolves.toEqual(verified); + }); +}); diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index e40427eae47..af69332038d 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -66,6 +66,11 @@ export interface BBJsApi { verifyAvmProof(proof: Uint8Array[], publicInputs: Uint8Array): Promise<{ verified: boolean; durationMs: number }>; /** Check the AVM circuit from serialized inputs. Returns pass/fail and per-stage timings. */ checkAvmCircuit(inputs: Uint8Array): Promise<{ passed: boolean; stats: AvmStat[]; durationMs: number }>; + /** + * Whether the bb process behind this instance is running and connected. An instance that is not alive never recovers, + * and every later call on it fails. + */ + isAlive(): boolean; destroy(): Promise; } @@ -238,6 +243,10 @@ export class BBJsInstance implements BBJsApi { return { passed: result.passed, stats: result.stats, durationMs: timer.ms() }; } + isAlive(): boolean { + return this.api.isAlive(); + } + /** Destroy this instance and kill the underlying bb process. */ async destroy(): Promise { await this.api.destroy(); @@ -262,6 +271,9 @@ export interface BBJsFactoryOptions { * set of long-lived bb processes that are reused across calls — useful when the per-call * bb startup cost dominates the workload (e.g. high-rate IVC verification). * + * A pooled instance whose bb process died is never handed out again: it is destroyed when it is returned or found idle, + * and a replacement is spawned when a borrower finds no idle instance while the pool is below `poolSize`. + * * Idiomatic usage: * ``` * await using inst = await factory.getInstance(); @@ -279,6 +291,8 @@ export class BBJsFactory { private pool?: FifoMemoryQueue; /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ private initPromise?: Promise; + /** Pooled instances that exist, idle or borrowed, plus spawns in flight. Below `poolSize` after an eviction. */ + private pooledCount = 0; private destroyed = false; constructor( @@ -296,8 +310,9 @@ export class BBJsFactory { /** * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. - * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows from - * the pool and returns to it on dispose. + * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows a live instance from + * the pool and returns it on dispose, spawning a replacement first if the pool has no idle instance and is below + * `poolSize`. Throws if that spawn fails; the next call tries again. */ async getInstance(): Promise { if (this.destroyed) { @@ -308,19 +323,27 @@ export class BBJsFactory { const instance = await this.createInstance(); return this.makeOwned(instance); } - if (!this.initPromise) { - this.initPromise = this.initPool(); - } - await this.initPromise; - const pool = this.pool; - if (!pool) { - throw new Error('BBJsFactory has been destroyed'); - } - const instance = await pool.get(); - if (!instance) { - throw new Error('BBJsFactory was destroyed while waiting for an instance'); + await this.ensurePoolInitialized(); + // Every idle instance can turn out dead, and each one found dead makes room for a replacement, so poolSize + 1 + // attempts always reach a live instance unless replacements keep dying too. + for (let attempt = 0; attempt <= this.poolSize; attempt++) { + const pool = this.pool; + if (!pool) { + throw new Error('BBJsFactory has been destroyed'); + } + if (pool.length() === 0) { + await this.replenish(pool); + } + const instance = await pool.get(); + if (!instance) { + throw new Error('BBJsFactory was destroyed while waiting for an instance'); + } + if (instance.isAlive()) { + return this.makeBorrowed(instance); + } + await this.evict(instance); } - return this.makeBorrowed(instance); + throw new Error(`BBJsFactory found no live bb instance after ${this.poolSize + 1} attempts`); } /** @@ -354,6 +377,28 @@ export class BBJsFactory { } } + protected async createInstance(): Promise { + const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; + const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads); + return this.maybeWrapDebug(raw); + } + + /** Initializes the pool once; a failed initialization is retried by the next call. */ + private async ensurePoolInitialized(): Promise { + if (!this.initPromise) { + this.initPromise = this.initPool(); + } + const initPromise = this.initPromise; + try { + await initPromise; + } catch (err) { + if (this.initPromise === initPromise) { + this.initPromise = undefined; + } + throw err; + } + } + private async initPool(): Promise { // Use allSettled so that if any createInstance() rejects we can destroy the rest instead of // leaking bb child processes whose creation succeeded. @@ -379,13 +424,40 @@ export class BBJsFactory { for (const item of items) { pool.put(item); } + this.pooledCount = items.length; this.pool = pool; } - private async createInstance(): Promise { - const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; - const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads); - return this.maybeWrapDebug(raw); + /** Spawns one pooled instance into `pool` if the pool is below `poolSize`. */ + private async replenish(pool: FifoMemoryQueue): Promise { + if (this.pooledCount >= this.poolSize!) { + return; + } + // Counted before the spawn so that concurrent borrowers do not spawn past poolSize. + this.pooledCount++; + let instance: BBJsApi; + try { + instance = await this.createInstance(); + } catch (err) { + this.pooledCount--; + throw err; + } + if (this.destroyed) { + await instance.destroy(); + return; + } + pool.put(instance); + } + + /** Destroys a pooled instance whose bb process died, making room in the pool for a replacement. */ + private async evict(instance: BBJsApi): Promise { + this.pooledCount--; + this.logger?.warn('Evicting a pooled bb instance whose process died', { + poolSize: this.poolSize, + pooledCount: this.pooledCount, + }); + // bb is already gone, so a teardown error is not actionable and must not fail the borrow that found it. + await instance.destroy().catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); } /** Wrap the instance in a debug wrapper if debugDir is configured. */ @@ -407,16 +479,18 @@ export class BBJsFactory { } /** - * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool (or destroys it - * if the factory was destroyed in the meantime). Destroy errors are propagated. + * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool, evicts it if its bb process died, or + * destroys it if the factory was destroyed in the meantime. Destroy errors of a live instance are propagated. */ private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { return this.makeDisposable(instance, async () => { const pool = this.pool; - if (pool && !this.destroyed) { + if (!pool || this.destroyed) { + await instance.destroy(); + } else if (instance.isAlive()) { pool.put(instance); } else { - await instance.destroy(); + await this.evict(instance); } }); } diff --git a/yarn-project/bb-prover/src/bb/bb_js_debug.ts b/yarn-project/bb-prover/src/bb/bb_js_debug.ts index c76d176caed..13e7e836b02 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_debug.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_debug.ts @@ -221,6 +221,10 @@ export class DebugBBJsInstance implements BBJsApi { return this.inner.generateContract(verificationKey); } + isAlive(): boolean { + return this.inner.isAlive(); + } + destroy(): Promise { return this.inner.destroy(); } diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts new file mode 100644 index 00000000000..4e0d5ce5e6d --- /dev/null +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -0,0 +1,107 @@ +import type { AvmStat } from '@aztec-foundation/bb.js'; + +import { type BBJsApi, BBJsFactory, type BBJsProofResult } from '../bb/bb_js_backend.js'; + +/** How a {@link FakeBBJsInstance} answers one `verifyChonkProof` call. */ +export type FakeChonkVerifyOutcome = 'valid' | 'invalid' | 'bb-error' | 'die'; + +function notImplemented(): Promise { + return Promise.reject(new Error('Not implemented by FakeBBJsInstance')); +} + +/** A {@link BBJsApi} double whose bb process can die. Only `verifyChonkProof` is implemented. */ +export class FakeBBJsInstance implements BBJsApi { + public destroyCount = 0; + public chonkVerifyCalls = 0; + private alive = true; + + /** @param outcomes - Answers to successive `verifyChonkProof` calls; `valid` once they run out. */ + constructor(private readonly outcomes: FakeChonkVerifyOutcome[] = []) {} + + /** Simulates the bb process dying: every later call fails as it does on a closed socket. */ + public kill(): void { + this.alive = false; + } + + public isAlive(): boolean { + return this.alive; + } + + public verifyChonkProof(): Promise<{ verified: boolean; durationMs: number }> { + this.chonkVerifyCalls++; + if (!this.alive) { + return Promise.reject(new Error('Socket not connected')); + } + switch (this.outcomes.shift() ?? 'valid') { + case 'valid': + return Promise.resolve({ verified: true, durationMs: 1 }); + case 'invalid': + return Promise.resolve({ verified: false, durationMs: 1 }); + case 'bb-error': + return Promise.reject(new Error('bb rejected the proof input')); + case 'die': + this.kill(); + return Promise.reject(new Error('Socket connection ended unexpectedly')); + } + } + + public destroy(): Promise { + this.alive = false; + this.destroyCount++; + return Promise.resolve(); + } + + public generateProof(): Promise { + return notImplemented(); + } + + public verifyProof(): Promise<{ verified: boolean; durationMs: number }> { + return notImplemented(); + } + + public computeGateCount(): Promise<{ circuitSize: number; durationMs: number }> { + return notImplemented(); + } + + public generateContract(): Promise<{ solidityCode: string; durationMs: number }> { + return notImplemented(); + } + + public generateAvmProof(): Promise<{ proof: Uint8Array[]; stats: AvmStat[]; durationMs: number }> { + return notImplemented(); + } + + public verifyAvmProof(): Promise<{ verified: boolean; durationMs: number }> { + return notImplemented(); + } + + public checkAvmCircuit(): Promise<{ passed: boolean; stats: AvmStat[]; durationMs: number }> { + return notImplemented(); + } +} + +/** A pooled {@link BBJsFactory} that creates {@link FakeBBJsInstance}s instead of spawning bb. */ +export class FakeBBJsFactory extends BBJsFactory { + /** Every instance created, in creation order. */ + public readonly created: FakeBBJsInstance[] = []; + private readonly plan: (FakeChonkVerifyOutcome[] | Error)[] = []; + + constructor(poolSize: number) { + super('/unused/bb', { poolSize }); + } + + /** Scripts the next creation: an error makes that spawn fail, outcomes script the created instance's verifications. */ + public planNextInstance(next: FakeChonkVerifyOutcome[] | Error): void { + this.plan.push(next); + } + + protected override createInstance(): Promise { + const next = this.plan.shift() ?? []; + if (next instanceof Error) { + return Promise.reject(next); + } + const instance = new FakeBBJsInstance(next); + this.created.push(instance); + return Promise.resolve(instance); + } +} From edc466919959f8d0e4e48390039b9e06461814c1 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 10:27:21 +0000 Subject: [PATCH 02/19] fix(bb-prover): report a dead bb verifier as unavailable, not as an invalid proof BBCircuitVerifier.verifyProof turned every error into { valid: false }, so a bb process that died was reported as an invalid transaction proof and counted as a verification failure. A call that fails because its bb died is now retried once on another pooled instance; if that bb dies too, or no instance can be started, verifyProof throws ProofVerifierUnavailableError. RPC admission then fails with that error instead of rejecting the tx as invalid, and QueuedIVCVerifier does not record it as a failed verification. A call that fails while bb is alive still means bb rejected the proof. --- .../src/verifier/bb_verifier.test.ts | 80 ++++++++++++++++++ .../bb-prover/src/verifier/bb_verifier.ts | 81 ++++++++++++++++--- 2 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 yarn-project/bb-prover/src/verifier/bb_verifier.test.ts diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts new file mode 100644 index 00000000000..e8e703c9ebe --- /dev/null +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@aztec-labs/foundation/log'; +import { mockTx } from '@aztec-labs/stdlib/testing'; +import type { Tx } from '@aztec-labs/stdlib/tx'; + +import type { BBJsFactory } from '../bb/bb_js_backend.js'; +import type { BBConfig } from '../config.js'; +import { FakeBBJsFactory } from '../test/fake_bb_js.js'; +import { BBCircuitVerifier, ProofVerifierUnavailableError } from './bb_verifier.js'; + +const config: BBConfig = { + bbBinaryPath: '/unused/bb', + bbWorkingDirectory: '/unused/bb-working-directory', + bbSkipCleanup: false, + numConcurrentIVCVerifiers: 1, + bbIVCConcurrency: 1, + bbChonkVerifyMaxBatch: 1, + bbChonkVerifyConcurrency: 1, +}; + +/** A BBCircuitVerifier over an injected bb.js factory. */ +class TestBBCircuitVerifier extends BBCircuitVerifier { + constructor(factory: BBJsFactory) { + super(config, createLogger('bb-prover:verifier:test'), factory); + } +} + +describe('BBCircuitVerifier', () => { + let factory: FakeBBJsFactory; + let verifier: TestBBCircuitVerifier; + let tx: Tx; + + beforeEach(async () => { + factory = new FakeBBJsFactory(1); + verifier = new TestBBCircuitVerifier(factory); + tx = await mockTx(); + }); + + afterEach(async () => { + await verifier.stop(); + }); + + it('accepts a proof bb verifies', async () => { + await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); + }); + + it('rejects a proof bb reports as not verified', async () => { + factory.planNextInstance(['invalid']); + await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: false }); + }); + + it('rejects a proof bb errors on while alive', async () => { + factory.planNextInstance(['bb-error']); + await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: false }); + expect(factory.created).toHaveLength(1); + }); + + it('retries on a replacement instance when bb dies during verification', async () => { + factory.planNextInstance(['die']); + await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); + expect(factory.created).toHaveLength(2); + expect(factory.created[0].destroyCount).toBe(1); + }); + + it('reports the verifier unavailable, not the proof invalid, when bb dies on every attempt', async () => { + factory.planNextInstance(['die']); + factory.planNextInstance(['die']); + await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + }); + + it('reports the verifier unavailable when no bb instance can be started', async () => { + factory.planNextInstance(new Error('spawn failed')); + await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + }); + + it('verifies again once a failed bb spawn succeeds', async () => { + factory.planNextInstance(new Error('spawn failed')); + await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); + }); +}); diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index 5183dabfd71..1de54ec6615 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -14,24 +14,38 @@ import { Tx } from '@aztec-labs/stdlib/tx'; import type { VerificationKeyData } from '@aztec-labs/stdlib/vks'; import { promises as fs } from 'fs'; -import { BBJsFactory } from '../bb/bb_js_backend.js'; +import { type BBJsApi, BBJsFactory } from '../bb/bb_js_backend.js'; import type { BBConfig } from '../config.js'; import { getUltraHonkFlavorForCircuit } from '../honk.js'; +/** Thrown when no live bb process could check a proof, so the proof was neither accepted nor rejected. */ +export class ProofVerifierUnavailableError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ProofVerifierUnavailableError'; + } +} + export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { + /** bb instances a Chonk verification tries, while each one's bb dies under it, before the verifier is unavailable. */ + private static readonly MAX_CHONK_VERIFY_ATTEMPTS = 2; + private bbJsFactory: BBJsFactory; - private constructor( + protected constructor( private config: BBConfig, private logger: Logger, + bbJsFactory?: BBJsFactory, ) { // BB_NUM_IVC_VERIFIERS bounds the number of long-lived bb processes the pool keeps alive. // If 0, fall back to spawning a fresh bb per verification. - this.bbJsFactory = new BBJsFactory(config.bbBinaryPath, { - poolSize: config.numConcurrentIVCVerifiers > 0 ? config.numConcurrentIVCVerifiers : undefined, - logger, - debugDir: config.bbDebugOutputDir, - }); + this.bbJsFactory = + bbJsFactory ?? + new BBJsFactory(config.bbBinaryPath, { + poolSize: config.numConcurrentIVCVerifiers > 0 ? config.numConcurrentIVCVerifiers : undefined, + logger, + debugDir: config.bbDebugOutputDir, + }); } public stop(): Promise { @@ -85,9 +99,13 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { } satisfies CircuitVerificationStats); } - /** Verify a Chonk (IVC) proof from a transaction via bb.js API. */ + /** + * Verify a Chonk (IVC) proof from a transaction via bb.js API. Returns `valid: false` when bb checked the proof and + * rejected it, and throws {@link ProofVerifierUnavailableError} when no live bb process could check it. + */ public async verifyProof(tx: Tx): Promise { const proofType = 'Chonk'; + const txHash = tx.getTxHash().toString(); try { const totalTimer = new Timer(); @@ -98,8 +116,11 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { const proofWithPubInputs = tx.chonkProof.attachPublicInputs(tx.data.publicInputs().toFields()); const fieldsAsBuffers = proofWithPubInputs.fieldsWithPublicInputs.map(f => new Uint8Array(f.toBuffer())); - await using instance = await this.bbJsFactory.getInstance(); - const { verified, durationMs } = await instance.verifyChonkProof(fieldsAsBuffers, verificationKey.keyAsBytes); + const { verified, durationMs } = await this.verifyChonkProofOnLiveInstance( + fieldsAsBuffers, + verificationKey.keyAsBytes, + txHash, + ); if (!verified) { throw new Error(`Failed to verify ${proofType} proof for ${circuit}!`); @@ -114,10 +135,48 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { return { valid: true, durationMs, totalDurationMs: totalTimer.ms() }; } catch (err) { - this.logger.warn(`Failed to verify ${proofType} proof for tx ${tx.getTxHash().toString()}: ${String(err)}`); + if (err instanceof ProofVerifierUnavailableError) { + throw err; + } + this.logger.warn(`Failed to verify ${proofType} proof for tx ${txHash}: ${String(err)}`); return { valid: false, durationMs: 0, totalDurationMs: 0 }; } } + + /** + * Runs a Chonk verification on a pooled bb instance. A call that fails because the instance's bb died is retried on + * another instance; a call that fails while bb is alive is bb rejecting the proof, and its error is rethrown. + */ + private async verifyChonkProofOnLiveInstance( + fieldsWithPublicInputs: Uint8Array[], + verificationKey: Uint8Array, + txHash: string, + ): Promise<{ verified: boolean; durationMs: number }> { + for (let attempt = 1; ; attempt++) { + await using instance = await this.borrowInstance(); + try { + return await instance.verifyChonkProof(fieldsWithPublicInputs, verificationKey); + } catch (err) { + if (instance.isAlive()) { + throw err; + } + if (attempt >= BBCircuitVerifier.MAX_CHONK_VERIFY_ATTEMPTS) { + throw new ProofVerifierUnavailableError(`bb died while verifying the proof, on ${attempt} instances`, { + cause: err, + }); + } + this.logger.warn('bb died while verifying a proof; retrying on another instance', { txHash, attempt }); + } + } + } + + private async borrowInstance(): Promise { + try { + return await this.bbJsFactory.getInstance(); + } catch (err) { + throw new ProofVerifierUnavailableError('No bb instance available to verify the proof', { cause: err }); + } + } } /** Split a buffer into 32-byte Uint8Array field elements. */ From 380f29924b4b895632853931ad718c5f88d38988 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 10:42:55 +0000 Subject: [PATCH 03/19] fix(bb-prover): replace a dead borrowed bb instance on return and keep a partly started pool A borrower waiting on a full pool was stranded when a borrowed instance died, because the eviction freed room without spawning anything. Returning a dead instance now spawns its replacement, which goes straight to a waiting borrower. A borrow whose replacement spawn fails waits for a borrowed instance instead of failing, and only throws when the pool holds none. Pool initialization keeps the instances that started and spawns the rest on demand, rather than destroying them all when one fails. --- .../bb-prover/src/bb/bb_js_backend.test.ts | 53 +++++++++++++++++-- .../bb-prover/src/bb/bb_js_backend.ts | 45 ++++++++++++---- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index ade57e7ce57..f94b741eec8 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -69,20 +69,65 @@ describe('BBJsFactory pool', () => { expect(failures).toBe(1); }); - it('fails the borrow when a replacement cannot be spawned, and spawns it on the next borrow', async () => { + it('hands the replacement for a dead borrowed instance to a borrower waiting for it', async () => { + factory = new FakeBBJsFactory(1); + const first = await factory.getInstance(); + const waiting = factory.getInstance(); + // Let the second borrow start waiting on the full pool before the borrowed instance dies. + await new Promise(resolve => setImmediate(resolve)); + factory.created[0].kill(); + await first[Symbol.asyncDispose](); + + await using second = await waiting; + await expect(verify(second)).resolves.toEqual(verified); + expect(factory.created).toHaveLength(2); + }); + + it('fails a borrow when no instance exists and none can be spawned, and spawns one on the next borrow', async () => { factory = new FakeBBJsFactory(1); { - await using first = await factory.getInstance(); + await using _first = await factory.getInstance(); factory.created[0].kill(); + // One spawn when the dead instance is returned, one by the next borrow. + factory.planNextInstance(new Error('spawn failed')); + factory.planNextInstance(new Error('spawn failed')); } - factory.planNextInstance(new Error('spawn failed')); await expect(factory.getInstance()).rejects.toThrow('spawn failed'); await using second = await factory.getInstance(); await expect(verify(second)).resolves.toEqual(verified); }); - it('retries pool initialization after a failed spawn', async () => { + it('waits for a borrowed instance when a replacement cannot be spawned', async () => { + factory = new FakeBBJsFactory(2); + const first = await factory.getInstance(); + { + await using _second = await factory.getInstance(); + factory.created[1].kill(); + factory.planNextInstance(new Error('spawn failed')); + factory.planNextInstance(new Error('spawn failed')); + } + + const waiting = factory.getInstance(); + // Let the borrow fail its spawn and start waiting before the live instance comes back. + await new Promise(resolve => setImmediate(resolve)); + await first[Symbol.asyncDispose](); + + await using third = await waiting; + await expect(verify(third)).resolves.toEqual(verified); + expect(factory.created).toHaveLength(2); + }); + + it('keeps the instances that started when pool initialization partly fails', async () => { + factory = new FakeBBJsFactory(2); + factory.planNextInstance(new Error('spawn failed')); + + await using instance = await factory.getInstance(); + await expect(verify(instance)).resolves.toEqual(verified); + expect(factory.created).toHaveLength(1); + }); + + it('retries pool initialization when no instance started', async () => { factory = new FakeBBJsFactory(1); factory.planNextInstance(new Error('spawn failed')); diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index af69332038d..dec84ab36f7 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -271,8 +271,9 @@ export interface BBJsFactoryOptions { * set of long-lived bb processes that are reused across calls — useful when the per-call * bb startup cost dominates the workload (e.g. high-rate IVC verification). * - * A pooled instance whose bb process died is never handed out again: it is destroyed when it is returned or found idle, - * and a replacement is spawned when a borrower finds no idle instance while the pool is below `poolSize`. + * A pooled instance whose bb process died is never handed out again: it is destroyed when it is returned or found idle. + * A replacement is spawned when a dead instance is returned, and when a borrower finds no idle instance while the pool + * is below `poolSize`. * * Idiomatic usage: * ``` @@ -312,7 +313,8 @@ export class BBJsFactory { * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows a live instance from * the pool and returns it on dispose, spawning a replacement first if the pool has no idle instance and is below - * `poolSize`. Throws if that spawn fails; the next call tries again. + * `poolSize`. If that spawn fails, waits for a borrowed instance, or throws when there is none; the next call tries + * the spawn again. */ async getInstance(): Promise { if (this.destroyed) { @@ -332,7 +334,17 @@ export class BBJsFactory { throw new Error('BBJsFactory has been destroyed'); } if (pool.length() === 0) { - await this.replenish(pool); + try { + await this.replenish(pool); + } catch (err) { + if (this.pooledCount === 0) { + throw err; + } + this.logger?.warn('Failed to spawn a bb instance; waiting for a borrowed one', { + pooledCount: this.pooledCount, + err, + }); + } } const instance = await pool.get(); if (!instance) { @@ -400,8 +412,8 @@ export class BBJsFactory { } private async initPool(): Promise { - // Use allSettled so that if any createInstance() rejects we can destroy the rest instead of - // leaking bb child processes whose creation succeeded. + // Use allSettled so that the bb child processes whose creation succeeded are kept when others fail, and are + // destroyed rather than leaked when destroy() raced ahead. const results = await Promise.allSettled(Array.from({ length: this.poolSize! }, () => this.createInstance())); const items: BBJsApi[] = []; const errors: unknown[] = []; @@ -412,14 +424,21 @@ export class BBJsFactory { errors.push(result.reason); } } - if (errors.length > 0 || this.destroyed) { - // Either creation failed or destroy() raced ahead — clean up everything we successfully spawned. + if (this.destroyed) { await Promise.all(items.map(item => item.destroy())); - if (errors.length > 0) { - throw errors[0]; - } return; } + if (items.length === 0) { + throw errors[0]; + } + if (errors.length > 0) { + // The missing instances are spawned on demand, like replacements for dead ones. + this.logger?.warn('Some pooled bb instances failed to start', { + poolSize: this.poolSize, + started: items.length, + err: errors[0], + }); + } const pool = new FifoMemoryQueue(); for (const item of items) { pool.put(item); @@ -491,6 +510,10 @@ export class BBJsFactory { pool.put(instance); } else { await this.evict(instance); + // A borrower may be waiting for this instance to come back, so replace it now rather than on the next borrow. + await this.replenish(pool).catch(err => + this.logger?.warn('Failed to spawn a replacement for a dead bb instance', { err }), + ); } }); } From b6153551a56c9d94f1aa46c27ce62b31aa5e219e Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 10:42:58 +0000 Subject: [PATCH 04/19] docs(bb-prover): state that verifyProof rejects when a proof cannot be checked ClientProtocolCircuitVerifier.verifyProof documents that it rejects, rather than reporting the proof invalid, when the proof could not be checked. BBCircuitVerifier's docs no longer claim that every failure while bb is alive is a rejection of the proof, and its failure log is structured. --- yarn-project/bb-prover/src/verifier/bb_verifier.ts | 8 ++++---- .../stdlib/src/interfaces/server_circuit_prover.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index 1de54ec6615..42cac5c4a56 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -100,8 +100,8 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { } /** - * Verify a Chonk (IVC) proof from a transaction via bb.js API. Returns `valid: false` when bb checked the proof and - * rejected it, and throws {@link ProofVerifierUnavailableError} when no live bb process could check it. + * Verify a Chonk (IVC) proof from a transaction via bb.js API. Throws {@link ProofVerifierUnavailableError} when no + * live bb process could check the proof; any other failure returns `valid: false`. */ public async verifyProof(tx: Tx): Promise { const proofType = 'Chonk'; @@ -138,14 +138,14 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { if (err instanceof ProofVerifierUnavailableError) { throw err; } - this.logger.warn(`Failed to verify ${proofType} proof for tx ${txHash}: ${String(err)}`); + this.logger.warn(`Failed to verify ${proofType} proof`, { txHash, err }); return { valid: false, durationMs: 0, totalDurationMs: 0 }; } } /** * Runs a Chonk verification on a pooled bb instance. A call that fails because the instance's bb died is retried on - * another instance; a call that fails while bb is alive is bb rejecting the proof, and its error is rethrown. + * another instance; any other failure is rethrown. */ private async verifyChonkProofOnLiveInstance( fieldsWithPublicInputs: Uint8Array[], diff --git a/yarn-project/stdlib/src/interfaces/server_circuit_prover.ts b/yarn-project/stdlib/src/interfaces/server_circuit_prover.ts index 459bb4196ee..4254dc59232 100644 --- a/yarn-project/stdlib/src/interfaces/server_circuit_prover.ts +++ b/yarn-project/stdlib/src/interfaces/server_circuit_prover.ts @@ -182,9 +182,10 @@ export type IVCProofVerificationResult = { */ export interface ClientProtocolCircuitVerifier { /** - * Verifies the private protocol circuit's proof. + * Verifies the private protocol circuit's proof. Rejects, instead of reporting the proof invalid, when the proof + * could not be checked (for example, when the proving backend is unavailable). * @param tx - The tx to verify the proof of - * @returns True if the proof is valid, false otherwise + * @returns Whether the proof is valid, with verification timings */ verifyProof(tx: Tx): Promise; From bea5d941a25f58f25d2a7ef45fdd92bbeedd2bb3 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 10:47:57 +0000 Subject: [PATCH 05/19] fix(bb-prover): re-check waiting pool borrowers so a failed spawn cannot strand them A borrower waiting on an empty pool counted on an in-flight spawn or a borrowed instance to come back, and was never woken when the spawn failed or the instance died without a replacement. Waiting borrowers now re-check every second: they spawn an instance themselves when the pool is below its size, and throw when that fails while no instance exists or is being spawned. A dead instance's replacement is spawned in the background, so returning it does not hold up the verification that found it dead. --- .../bb-prover/src/bb/bb_js_backend.test.ts | 24 +++++- .../bb-prover/src/bb/bb_js_backend.ts | 76 ++++++++++++------- yarn-project/bb-prover/src/test/fake_bb_js.ts | 1 + 3 files changed, 72 insertions(+), 29 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index f94b741eec8..1403ec6da0e 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -16,6 +16,8 @@ describe('BBJsFactory pool', () => { const verify = (instance: BBJsApi) => instance.verifyChonkProof([], new Uint8Array()); const verified = { verified: true, durationMs: 1 }; + // The fake spawns settle within microtasks, so one macrotask lets every pending spawn and waiting borrow progress. + const settle = () => new Promise(resolve => setImmediate(resolve)); afterEach(async () => { await factory.destroy(); @@ -74,7 +76,7 @@ describe('BBJsFactory pool', () => { const first = await factory.getInstance(); const waiting = factory.getInstance(); // Let the second borrow start waiting on the full pool before the borrowed instance dies. - await new Promise(resolve => setImmediate(resolve)); + await settle(); factory.created[0].kill(); await first[Symbol.asyncDispose](); @@ -83,6 +85,22 @@ describe('BBJsFactory pool', () => { expect(factory.created).toHaveLength(2); }); + it('fails waiting borrows once the instance they wait for dies and cannot be replaced', async () => { + factory = new FakeBBJsFactory(1); + const first = await factory.getInstance(); + const waiting = [factory.getInstance(), factory.getInstance()]; + await settle(); + factory.created[0].kill(); + // More failures than spawns: the background replacement, then a spawn by each waiting borrow when it re-checks. + for (let i = 0; i < 10; i++) { + factory.planNextInstance(new Error('spawn failed')); + } + await first[Symbol.asyncDispose](); + + const results = await Promise.allSettled(waiting); + expect(results.map(r => r.status)).toEqual(['rejected', 'rejected']); + }); + it('fails a borrow when no instance exists and none can be spawned, and spawns one on the next borrow', async () => { factory = new FakeBBJsFactory(1); { @@ -92,6 +110,7 @@ describe('BBJsFactory pool', () => { factory.planNextInstance(new Error('spawn failed')); factory.planNextInstance(new Error('spawn failed')); } + await settle(); await expect(factory.getInstance()).rejects.toThrow('spawn failed'); await using second = await factory.getInstance(); @@ -107,10 +126,11 @@ describe('BBJsFactory pool', () => { factory.planNextInstance(new Error('spawn failed')); factory.planNextInstance(new Error('spawn failed')); } + await settle(); const waiting = factory.getInstance(); // Let the borrow fail its spawn and start waiting before the live instance comes back. - await new Promise(resolve => setImmediate(resolve)); + await settle(); await first[Symbol.asyncDispose](); await using third = await waiting; diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index dec84ab36f7..152ec49c9b1 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -1,5 +1,6 @@ import { type AvmStat, type BackendOptions, BackendType, Barretenberg } from '@aztec-foundation/bb.js'; +import { TimeoutError } from '@aztec-labs/foundation/error'; import type { LogFn, Logger } from '@aztec-labs/foundation/log'; import { FifoMemoryQueue } from '@aztec-labs/foundation/queue'; import { Timer } from '@aztec-labs/foundation/timer'; @@ -271,9 +272,9 @@ export interface BBJsFactoryOptions { * set of long-lived bb processes that are reused across calls — useful when the per-call * bb startup cost dominates the workload (e.g. high-rate IVC verification). * - * A pooled instance whose bb process died is never handed out again: it is destroyed when it is returned or found idle. - * A replacement is spawned when a dead instance is returned, and when a borrower finds no idle instance while the pool - * is below `poolSize`. + * A pooled instance whose bb process died is never handed out again: it is destroyed when it is returned or found idle, + * and a replacement is spawned in the background. A borrower that finds no idle instance while the pool is below + * `poolSize` also spawns one. * * Idiomatic usage: * ``` @@ -295,6 +296,8 @@ export class BBJsFactory { /** Pooled instances that exist, idle or borrowed, plus spawns in flight. Below `poolSize` after an eviction. */ private pooledCount = 0; private destroyed = false; + /** How often a borrower waiting on an empty pool re-checks whether it must spawn an instance itself or give up. */ + protected readonly waitRecheckSeconds: number = 1; constructor( private bbPath: string, @@ -313,8 +316,7 @@ export class BBJsFactory { * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows a live instance from * the pool and returns it on dispose, spawning a replacement first if the pool has no idle instance and is below - * `poolSize`. If that spawn fails, waits for a borrowed instance, or throws when there is none; the next call tries - * the spawn again. + * `poolSize`. Throws when no instance exists, none is being spawned, and a spawn fails; the next call tries again. */ async getInstance(): Promise { if (this.destroyed) { @@ -333,27 +335,14 @@ export class BBJsFactory { if (!pool) { throw new Error('BBJsFactory has been destroyed'); } - if (pool.length() === 0) { - try { - await this.replenish(pool); - } catch (err) { - if (this.pooledCount === 0) { - throw err; - } - this.logger?.warn('Failed to spawn a bb instance; waiting for a borrowed one', { - pooledCount: this.pooledCount, - err, - }); - } - } - const instance = await pool.get(); + const instance = await this.takeIdle(pool); if (!instance) { throw new Error('BBJsFactory was destroyed while waiting for an instance'); } if (instance.isAlive()) { return this.makeBorrowed(instance); } - await this.evict(instance); + await this.evict(instance, pool); } throw new Error(`BBJsFactory found no live bb instance after ${this.poolSize + 1} attempts`); } @@ -468,8 +457,40 @@ export class BBJsFactory { pool.put(instance); } - /** Destroys a pooled instance whose bb process died, making room in the pool for a replacement. */ - private async evict(instance: BBJsApi): Promise { + /** + * Takes an idle instance from `pool`, spawning one first when none is idle and the pool is below `poolSize`. While + * the pool stays empty it re-checks every `waitRecheckSeconds`: the instance it waits for may never come back (a + * spawn in flight fails, or a borrowed instance dies and its replacement fails), and it then spawns one itself. + * Throws when that spawn fails while no instance exists or is being spawned. Resolves to null once the factory is + * destroyed. + */ + private async takeIdle(pool: FifoMemoryQueue): Promise { + for (;;) { + if (pool.length() === 0) { + try { + await this.replenish(pool); + } catch (err) { + if (this.pooledCount === 0) { + throw err; + } + this.logger?.warn('Failed to spawn a bb instance; waiting for another one', { + pooledCount: this.pooledCount, + err, + }); + } + } + try { + return await pool.get(this.waitRecheckSeconds); + } catch (err) { + if (!(err instanceof TimeoutError)) { + throw err; + } + } + } + } + + /** Destroys a pooled instance whose bb process died and starts spawning its replacement. */ + private async evict(instance: BBJsApi, pool: FifoMemoryQueue): Promise { this.pooledCount--; this.logger?.warn('Evicting a pooled bb instance whose process died', { poolSize: this.poolSize, @@ -477,6 +498,11 @@ export class BBJsFactory { }); // bb is already gone, so a teardown error is not actionable and must not fail the borrow that found it. await instance.destroy().catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); + // Not awaited: a waiting borrower is served when the replacement arrives, and one that fails leaves the pool short + // until a borrower finds it empty and spawns again. + void this.replenish(pool).catch(err => + this.logger?.warn('Failed to spawn a replacement for a dead bb instance', { err }), + ); } /** Wrap the instance in a debug wrapper if debugDir is configured. */ @@ -509,11 +535,7 @@ export class BBJsFactory { } else if (instance.isAlive()) { pool.put(instance); } else { - await this.evict(instance); - // A borrower may be waiting for this instance to come back, so replace it now rather than on the next borrow. - await this.replenish(pool).catch(err => - this.logger?.warn('Failed to spawn a replacement for a dead bb instance', { err }), - ); + await this.evict(instance, pool); } }); } diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts index 4e0d5ce5e6d..3706aeee72d 100644 --- a/yarn-project/bb-prover/src/test/fake_bb_js.ts +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -84,6 +84,7 @@ export class FakeBBJsInstance implements BBJsApi { export class FakeBBJsFactory extends BBJsFactory { /** Every instance created, in creation order. */ public readonly created: FakeBBJsInstance[] = []; + protected override readonly waitRecheckSeconds = 0.01; private readonly plan: (FakeChonkVerifyOutcome[] | Error)[] = []; constructor(poolSize: number) { From 6511439e92e76668791f828d240c134189f25dea Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 10:50:05 +0000 Subject: [PATCH 06/19] fix(bb-prover): spawn no pooled bb instance after the factory is destroyed A borrower re-checking after destroy(), or an eviction just before it, spawned a bb only to destroy it. The pool test that waits for a borrowed instance also plans enough failed spawns for re-checks that land before that instance returns. --- yarn-project/bb-prover/src/bb/bb_js_backend.test.ts | 6 ++++-- yarn-project/bb-prover/src/bb/bb_js_backend.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index 1403ec6da0e..391515ac712 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -123,8 +123,10 @@ describe('BBJsFactory pool', () => { { await using _second = await factory.getInstance(); factory.created[1].kill(); - factory.planNextInstance(new Error('spawn failed')); - factory.planNextInstance(new Error('spawn failed')); + // Enough failures for the background replacement, the borrow's spawn, and any re-checks before `first` returns. + for (let i = 0; i < 10; i++) { + factory.planNextInstance(new Error('spawn failed')); + } } await settle(); diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 152ec49c9b1..089f6ff81cb 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -438,7 +438,7 @@ export class BBJsFactory { /** Spawns one pooled instance into `pool` if the pool is below `poolSize`. */ private async replenish(pool: FifoMemoryQueue): Promise { - if (this.pooledCount >= this.poolSize!) { + if (this.destroyed || this.pooledCount >= this.poolSize!) { return; } // Counted before the spawn so that concurrent borrowers do not spawn past poolSize. From b2da317c1be083b64fdb151b24ee6501adffc43f Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 11:45:43 +0000 Subject: [PATCH 07/19] fix(bb-prover): replace dead pooled bb instances from a periodic maintenance run The pool no longer spawns from the borrow path. Every second a maintenance run destroys the pooled instances whose bb died and spawns instances until the pool is back at poolSize, retrying failed spawns on the next run. A borrower skips dead instances and waits for a live one however long that takes, as it does while every instance is busy. destroy() does not wait for a spawn in flight; the spawn destroys its instance when it completes. QueuedIVCVerifier stops its verifier before draining its queue, so a verification waiting for a bb instance fails instead of blocking shutdown. --- .../bb-prover/src/bb/bb_js_backend.test.ts | 104 ++++----- .../bb-prover/src/bb/bb_js_backend.ts | 205 ++++++------------ yarn-project/bb-prover/src/test/fake_bb_js.ts | 37 +++- .../src/verifier/bb_verifier.test.ts | 25 ++- .../src/verifier/queued_chonk_verifier.ts | 3 +- 5 files changed, 172 insertions(+), 202 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index 391515ac712..598401a3a99 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -1,3 +1,6 @@ +import { promiseWithResolvers } from '@aztec-labs/foundation/promise'; +import { retryUntil } from '@aztec-labs/foundation/retry'; +import { sleep } from '@aztec-labs/foundation/sleep'; import { ProvingError } from '@aztec-labs/stdlib/errors'; import { FakeBBJsFactory } from '../test/fake_bb_js.js'; @@ -23,7 +26,7 @@ describe('BBJsFactory pool', () => { await factory.destroy(); }); - it('evicts a borrowed instance whose bb died instead of returning it to the pool', async () => { + it('does not return a borrowed instance whose bb died, and replaces it', async () => { factory = new FakeBBJsFactory(1); { await using first = await factory.getInstance(); @@ -38,7 +41,7 @@ describe('BBJsFactory pool', () => { expect(factory.created[1].chonkVerifyCalls).toBe(1); }); - it('skips an idle instance whose bb died and spawns a replacement', async () => { + it('skips an idle instance whose bb died and replaces it', async () => { factory = new FakeBBJsFactory(2); { await using _warmup = await factory.getInstance(); @@ -85,76 +88,77 @@ describe('BBJsFactory pool', () => { expect(factory.created).toHaveLength(2); }); - it('fails waiting borrows once the instance they wait for dies and cannot be replaced', async () => { + it('keeps a borrower waiting while replacements fail to spawn, and hands it the first one that starts', async () => { factory = new FakeBBJsFactory(1); const first = await factory.getInstance(); - const waiting = [factory.getInstance(), factory.getInstance()]; - await settle(); + const waiting = factory.getInstance(); factory.created[0].kill(); - // More failures than spawns: the background replacement, then a spawn by each waiting borrow when it re-checks. - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 3; i++) { factory.planNextInstance(new Error('spawn failed')); } await first[Symbol.asyncDispose](); - const results = await Promise.allSettled(waiting); - expect(results.map(r => r.status)).toEqual(['rejected', 'rejected']); + await using second = await waiting; + await expect(verify(second)).resolves.toEqual(verified); + expect(factory.created).toHaveLength(2); }); - it('fails a borrow when no instance exists and none can be spawned, and spawns one on the next borrow', async () => { + it('waits for the first instance when bb cannot start yet', async () => { factory = new FakeBBJsFactory(1); - { - await using _first = await factory.getInstance(); - factory.created[0].kill(); - // One spawn when the dead instance is returned, one by the next borrow. - factory.planNextInstance(new Error('spawn failed')); - factory.planNextInstance(new Error('spawn failed')); - } - await settle(); + factory.planNextInstance(new Error('spawn failed')); + factory.planNextInstance(new Error('spawn failed')); - await expect(factory.getInstance()).rejects.toThrow('spawn failed'); - await using second = await factory.getInstance(); - await expect(verify(second)).resolves.toEqual(verified); + await using instance = await factory.getInstance(); + await expect(verify(instance)).resolves.toEqual(verified); }); - it('waits for a borrowed instance when a replacement cannot be spawned', async () => { + it('keeps the instances that started and spawns the ones that failed later', async () => { factory = new FakeBBJsFactory(2); - const first = await factory.getInstance(); - { - await using _second = await factory.getInstance(); - factory.created[1].kill(); - // Enough failures for the background replacement, the borrow's spawn, and any re-checks before `first` returns. - for (let i = 0; i < 10; i++) { - factory.planNextInstance(new Error('spawn failed')); - } - } - await settle(); - - const waiting = factory.getInstance(); - // Let the borrow fail its spawn and start waiting before the live instance comes back. - await settle(); - await first[Symbol.asyncDispose](); + factory.planNextInstance(new Error('spawn failed')); - await using third = await waiting; - await expect(verify(third)).resolves.toEqual(verified); + await using a = await factory.getInstance(); + await using b = await factory.getInstance(); + await expect(verify(a)).resolves.toEqual(verified); + await expect(verify(b)).resolves.toEqual(verified); expect(factory.created).toHaveLength(2); }); - it('keeps the instances that started when pool initialization partly fails', async () => { - factory = new FakeBBJsFactory(2); - factory.planNextInstance(new Error('spawn failed')); - - await using instance = await factory.getInstance(); - await expect(verify(instance)).resolves.toEqual(verified); + it('does not spawn past poolSize while a spawn is in flight', async () => { + factory = new FakeBBJsFactory(1); + const spawned = promiseWithResolvers(); + factory.planNextInstance([], spawned.promise); + const borrowing = factory.getInstance(); + // Several maintenance runs happen while the spawn is in flight. + await sleep(50); + spawned.resolve(); + + await using _instance = await borrowing; expect(factory.created).toHaveLength(1); }); - it('retries pool initialization when no instance started', async () => { + it('does not wait for a spawn in flight when destroyed, and destroys its instance once it arrives', async () => { factory = new FakeBBJsFactory(1); - factory.planNextInstance(new Error('spawn failed')); + const spawned = promiseWithResolvers(); + factory.planNextInstance([], spawned.promise); + const borrowFails = expect(factory.getInstance()).rejects.toThrow('destroyed while waiting'); - await expect(factory.getInstance()).rejects.toThrow('spawn failed'); - await using instance = await factory.getInstance(); - await expect(verify(instance)).resolves.toEqual(verified); + await factory.destroy(); + await borrowFails; + spawned.resolve(); + await settle(); + expect(factory.created).toHaveLength(1); + expect(factory.created[0].destroyCount).toBe(1); + }); + + it('destroys every instance once when destroyed after replacing a dead idle one', async () => { + factory = new FakeBBJsFactory(2); + { + await using _warmup = await factory.getInstance(); + } + factory.created[1].kill(); + await retryUntil(() => factory.created.length === 3, 'replacement of the dead instance', 5, 0.001); + + await factory.destroy(); + expect(factory.created.map(instance => instance.destroyCount)).toEqual([1, 1, 1]); }); }); diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 089f6ff81cb..73ab42a511c 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -1,7 +1,7 @@ import { type AvmStat, type BackendOptions, BackendType, Barretenberg } from '@aztec-foundation/bb.js'; -import { TimeoutError } from '@aztec-labs/foundation/error'; import type { LogFn, Logger } from '@aztec-labs/foundation/log'; +import { RunningPromise } from '@aztec-labs/foundation/promise'; import { FifoMemoryQueue } from '@aztec-labs/foundation/queue'; import { Timer } from '@aztec-labs/foundation/timer'; import { ProvingError } from '@aztec-labs/stdlib/errors'; @@ -272,9 +272,9 @@ export interface BBJsFactoryOptions { * set of long-lived bb processes that are reused across calls — useful when the per-call * bb startup cost dominates the workload (e.g. high-rate IVC verification). * - * A pooled instance whose bb process died is never handed out again: it is destroyed when it is returned or found idle, - * and a replacement is spawned in the background. A borrower that finds no idle instance while the pool is below - * `poolSize` also spawns one. + * A pooled instance whose bb process died is never handed out again. Every `maintenanceIntervalMs` the pool destroys the + * dead instances and spawns new ones until it is back at `poolSize`, retrying failed spawns on the next run. A borrower + * waits for a live instance however long that takes, as it does while every instance is busy. * * Idiomatic usage: * ``` @@ -289,15 +289,17 @@ export class BBJsFactory { private readonly threads?: number; private readonly debugDir?: string; - /** Available pooled instances when poolSize is set; otherwise undefined. */ + /** Idle pooled instances, created by the first `getInstance()` call when poolSize is set. May hold dead ones. */ private pool?: FifoMemoryQueue; - /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ - private initPromise?: Promise; - /** Pooled instances that exist, idle or borrowed, plus spawns in flight. Below `poolSize` after an eviction. */ - private pooledCount = 0; + /** Every pooled instance, idle or borrowed, that has not been destroyed. */ + private members: BBJsApi[] = []; + /** Pooled instances being spawned. */ + private spawning = 0; + /** Runs {@link maintainPool} every `maintenanceIntervalMs` once the pool exists. */ + private maintenance?: RunningPromise; private destroyed = false; - /** How often a borrower waiting on an empty pool re-checks whether it must spawn an instance itself or give up. */ - protected readonly waitRecheckSeconds: number = 1; + /** How often the pool destroys dead instances and spawns the missing ones. */ + protected readonly maintenanceIntervalMs: number = 1000; constructor( private bbPath: string, @@ -314,9 +316,8 @@ export class BBJsFactory { /** * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. - * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows a live instance from - * the pool and returns it on dispose, spawning a replacement first if the pool has no idle instance and is below - * `poolSize`. Throws when no instance exists, none is being spawned, and a spawn fails; the next call tries again. + * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: waits for a live instance, borrows it, + * and returns it to the pool on dispose. Throws once the factory is destroyed, including while waiting. */ async getInstance(): Promise { if (this.destroyed) { @@ -327,30 +328,24 @@ export class BBJsFactory { const instance = await this.createInstance(); return this.makeOwned(instance); } - await this.ensurePoolInitialized(); - // Every idle instance can turn out dead, and each one found dead makes room for a replacement, so poolSize + 1 - // attempts always reach a live instance unless replacements keep dying too. - for (let attempt = 0; attempt <= this.poolSize; attempt++) { - const pool = this.pool; - if (!pool) { - throw new Error('BBJsFactory has been destroyed'); - } - const instance = await this.takeIdle(pool); + const pool = this.startPool(); + for (;;) { + const instance = await pool.get(); if (!instance) { throw new Error('BBJsFactory was destroyed while waiting for an instance'); } if (instance.isAlive()) { - return this.makeBorrowed(instance); + return this.makeBorrowed(instance, pool); } - await this.evict(instance, pool); + // Dropped from the idle queue; pool maintenance destroys and replaces it. } - throw new Error(`BBJsFactory found no live bb instance after ${this.poolSize + 1} attempts`); } /** * Tear down all pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call * instances are destroyed by their own dispose callbacks). Instances currently held by an - * in-flight pooled borrow are destroyed by their dispose callback when released. + * in-flight pooled borrow are destroyed by their dispose callback when released. Does not wait for a pooled instance + * being spawned, which is destroyed when its spawn completes. */ async destroy(): Promise { if (this.destroyed) { @@ -362,6 +357,7 @@ export class BBJsFactory { if (!pool) { return; } + await this.maintenance?.stop(); const idle: BBJsApi[] = []; while (pool.length() > 0) { const item = pool.getImmediate(); @@ -371,7 +367,7 @@ export class BBJsFactory { } pool.cancel(); // Aggregate teardown failures so a single bb child that fails to shut down doesn't mask others. - const results = await Promise.allSettled(idle.map(item => item.destroy())); + const results = await Promise.allSettled(idle.map(item => this.retire(item))); const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); if (errors.length > 0) { throw new AggregateError(errors, `BBJsFactory.destroy: ${errors.length} bb instance(s) failed to shut down`); @@ -384,125 +380,68 @@ export class BBJsFactory { return this.maybeWrapDebug(raw); } - /** Initializes the pool once; a failed initialization is retried by the next call. */ - private async ensurePoolInitialized(): Promise { - if (!this.initPromise) { - this.initPromise = this.initPool(); - } - const initPromise = this.initPromise; - try { - await initPromise; - } catch (err) { - if (this.initPromise === initPromise) { - this.initPromise = undefined; - } - throw err; + /** Creates the idle queue and starts pool maintenance, whose first run spawns the pool. */ + private startPool(): FifoMemoryQueue { + if (!this.pool) { + const pool = new FifoMemoryQueue(); + this.pool = pool; + this.maintenance = new RunningPromise(() => this.maintainPool(pool), this.logger, this.maintenanceIntervalMs); + this.maintenance.start(); } + return this.pool; } - private async initPool(): Promise { - // Use allSettled so that the bb child processes whose creation succeeded are kept when others fail, and are - // destroyed rather than leaked when destroy() raced ahead. - const results = await Promise.allSettled(Array.from({ length: this.poolSize! }, () => this.createInstance())); - const items: BBJsApi[] = []; - const errors: unknown[] = []; - for (const result of results) { - if (result.status === 'fulfilled') { - items.push(result.value); - } else { - errors.push(result.reason); - } - } - if (this.destroyed) { - await Promise.all(items.map(item => item.destroy())); - return; - } - if (items.length === 0) { - throw errors[0]; - } - if (errors.length > 0) { - // The missing instances are spawned on demand, like replacements for dead ones. - this.logger?.warn('Some pooled bb instances failed to start', { + /** + * Destroys the pooled instances whose bb process died and starts spawns until the pool is back at `poolSize`. Does not + * wait for the spawns, so that a slow spawn delays neither the next run nor `destroy()`. + */ + private maintainPool(pool: FifoMemoryQueue): void { + const dead = this.members.filter(member => !member.isAlive()); + if (dead.length > 0) { + this.logger?.warn('Replacing pooled bb instances whose process died', { poolSize: this.poolSize, - started: items.length, - err: errors[0], + dead: dead.length, }); } - const pool = new FifoMemoryQueue(); - for (const item of items) { - pool.put(item); + for (const instance of dead) { + // bb is already gone, so a teardown error is not actionable. + void this.retire(instance).catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); + } + for (let missing = this.poolSize! - this.members.length - this.spawning; missing > 0; missing--) { + void this.spawnMember(pool); } - this.pooledCount = items.length; - this.pool = pool; } - /** Spawns one pooled instance into `pool` if the pool is below `poolSize`. */ - private async replenish(pool: FifoMemoryQueue): Promise { - if (this.destroyed || this.pooledCount >= this.poolSize!) { - return; - } - // Counted before the spawn so that concurrent borrowers do not spawn past poolSize. - this.pooledCount++; + /** Spawns a pooled instance and queues it as idle, or destroys it if the factory was destroyed during the spawn. */ + private async spawnMember(pool: FifoMemoryQueue): Promise { + this.spawning++; let instance: BBJsApi; try { instance = await this.createInstance(); } catch (err) { - this.pooledCount--; - throw err; + this.logger?.warn('Failed to spawn a pooled bb instance', { poolSize: this.poolSize, err }); + return; + } finally { + this.spawning--; } if (this.destroyed) { - await instance.destroy(); + await instance + .destroy() + .catch(err => this.logger?.warn('Failed to destroy a bb instance spawned during shutdown', { err })); return; } + this.members.push(instance); pool.put(instance); } - /** - * Takes an idle instance from `pool`, spawning one first when none is idle and the pool is below `poolSize`. While - * the pool stays empty it re-checks every `waitRecheckSeconds`: the instance it waits for may never come back (a - * spawn in flight fails, or a borrowed instance dies and its replacement fails), and it then spawns one itself. - * Throws when that spawn fails while no instance exists or is being spawned. Resolves to null once the factory is - * destroyed. - */ - private async takeIdle(pool: FifoMemoryQueue): Promise { - for (;;) { - if (pool.length() === 0) { - try { - await this.replenish(pool); - } catch (err) { - if (this.pooledCount === 0) { - throw err; - } - this.logger?.warn('Failed to spawn a bb instance; waiting for another one', { - pooledCount: this.pooledCount, - err, - }); - } - } - try { - return await pool.get(this.waitRecheckSeconds); - } catch (err) { - if (!(err instanceof TimeoutError)) { - throw err; - } - } + /** Removes a pooled instance from the pool's members and destroys it, unless it was already removed. */ + private async retire(instance: BBJsApi): Promise { + const index = this.members.indexOf(instance); + if (index === -1) { + return; } - } - - /** Destroys a pooled instance whose bb process died and starts spawning its replacement. */ - private async evict(instance: BBJsApi, pool: FifoMemoryQueue): Promise { - this.pooledCount--; - this.logger?.warn('Evicting a pooled bb instance whose process died', { - poolSize: this.poolSize, - pooledCount: this.pooledCount, - }); - // bb is already gone, so a teardown error is not actionable and must not fail the borrow that found it. - await instance.destroy().catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); - // Not awaited: a waiting borrower is served when the replacement arrives, and one that fails leaves the pool short - // until a borrower finds it empty and spawns again. - void this.replenish(pool).catch(err => - this.logger?.warn('Failed to spawn a replacement for a dead bb instance', { err }), - ); + this.members.splice(index, 1); + await instance.destroy(); } /** Wrap the instance in a debug wrapper if debugDir is configured. */ @@ -524,18 +463,16 @@ export class BBJsFactory { } /** - * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool, evicts it if its bb process died, or - * destroys it if the factory was destroyed in the meantime. Destroy errors of a live instance are propagated. + * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool if it is alive, or destroys it if the + * factory was destroyed in the meantime. A dead instance is not returned, and pool maintenance destroys and replaces + * it. Destroy errors are propagated. */ - private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { + private makeBorrowed(instance: BBJsApi, pool: FifoMemoryQueue): BBJsApi & AsyncDisposable { return this.makeDisposable(instance, async () => { - const pool = this.pool; - if (!pool || this.destroyed) { - await instance.destroy(); + if (this.destroyed) { + await this.retire(instance); } else if (instance.isAlive()) { pool.put(instance); - } else { - await this.evict(instance, pool); } }); } diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts index 3706aeee72d..533245ac9d8 100644 --- a/yarn-project/bb-prover/src/test/fake_bb_js.ts +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -80,29 +80,44 @@ export class FakeBBJsInstance implements BBJsApi { } } -/** A pooled {@link BBJsFactory} that creates {@link FakeBBJsInstance}s instead of spawning bb. */ +/** A scripted {@link FakeBBJsFactory} spawn. */ +type PlannedSpawn = { + /** An error fails the spawn; outcomes script the created instance's verifications. */ + next: FakeChonkVerifyOutcome[] | Error; + /** When set, the spawn completes only once it resolves. */ + spawned?: Promise; +}; + +/** A {@link BBJsFactory} that creates {@link FakeBBJsInstance}s instead of spawning bb. */ export class FakeBBJsFactory extends BBJsFactory { /** Every instance created, in creation order. */ public readonly created: FakeBBJsInstance[] = []; - protected override readonly waitRecheckSeconds = 0.01; - private readonly plan: (FakeChonkVerifyOutcome[] | Error)[] = []; + protected override readonly maintenanceIntervalMs = 5; + private readonly plan: PlannedSpawn[] = []; - constructor(poolSize: number) { + /** @param poolSize - Pooled instances to keep; when omitted, every borrow creates a fresh instance. */ + constructor(poolSize?: number) { super('/unused/bb', { poolSize }); } - /** Scripts the next creation: an error makes that spawn fail, outcomes script the created instance's verifications. */ - public planNextInstance(next: FakeChonkVerifyOutcome[] | Error): void { - this.plan.push(next); + /** + * Scripts the next creation: an error makes that spawn fail, outcomes script the created instance's verifications. + * With `spawned`, the spawn completes only once it resolves. + */ + public planNextInstance(next: FakeChonkVerifyOutcome[] | Error, spawned?: Promise): void { + this.plan.push({ next, spawned }); } - protected override createInstance(): Promise { - const next = this.plan.shift() ?? []; + protected override async createInstance(): Promise { + const { next, spawned }: PlannedSpawn = this.plan.shift() ?? { next: [] }; + if (spawned) { + await spawned; + } if (next instanceof Error) { - return Promise.reject(next); + throw next; } const instance = new FakeBBJsInstance(next); this.created.push(instance); - return Promise.resolve(instance); + return instance; } } diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts index e8e703c9ebe..97169f0d624 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -6,6 +6,7 @@ import type { BBJsFactory } from '../bb/bb_js_backend.js'; import type { BBConfig } from '../config.js'; import { FakeBBJsFactory } from '../test/fake_bb_js.js'; import { BBCircuitVerifier, ProofVerifierUnavailableError } from './bb_verifier.js'; +import { QueuedIVCVerifier } from './queued_chonk_verifier.js'; const config: BBConfig = { bbBinaryPath: '/unused/bb', @@ -67,14 +68,26 @@ describe('BBCircuitVerifier', () => { await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); }); - it('reports the verifier unavailable when no bb instance can be started', async () => { + it('waits for a bb instance to start rather than rejecting the proof', async () => { factory.planNextInstance(new Error('spawn failed')); - await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); }); - it('verifies again once a failed bb spawn succeeds', async () => { - factory.planNextInstance(new Error('spawn failed')); - await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); - await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); + it('reports the verifier unavailable when a per-call bb instance cannot be started', async () => { + const perCallFactory = new FakeBBJsFactory(); + perCallFactory.planNextInstance(new Error('spawn failed')); + const perCallVerifier = new TestBBCircuitVerifier(perCallFactory); + await expect(perCallVerifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + }); + + it('stops a queued verifier while a verification waits for a bb instance that never starts', async () => { + factory.planNextInstance([], new Promise(() => {})); + const queued = new QueuedIVCVerifier(verifier, 1); + const verificationFails = expect(queued.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + // Let the verification start waiting for the pool before stopping. + await new Promise(resolve => setImmediate(resolve)); + + await queued.stop(); + await verificationFails; }); }); diff --git a/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts b/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts index 1bb122f8388..71a1afe0776 100644 --- a/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts @@ -102,7 +102,8 @@ export class QueuedIVCVerifier implements ClientProtocolCircuitVerifier { } async stop(): Promise { - await this.queue.end(); + // Stopped first so that verifications waiting for the verifier fail, rather than keep the queue from draining. await this.verifier.stop(); + await this.queue.end(); } } From 0f4d7cc4546bf0094909ca16fc4ea0e06ab1095f Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 11:58:55 +0000 Subject: [PATCH 08/19] fix(bb-prover): destroy dead pooled bb instances a borrow dropped when the factory is destroyed destroy() retires every dead member as well as the idle ones, so a dead instance a borrow dropped between maintenance runs is destroyed. Dispose returns an instance to the pool as before; the borrow path skips it if it is dead. QueuedIVCVerifier.stop() drains its queue even if stopping the verifier fails. --- .../bb-prover/src/bb/bb_js_backend.test.ts | 23 +++++++++++++--- .../bb-prover/src/bb/bb_js_backend.ts | 27 ++++++++++--------- yarn-project/bb-prover/src/test/fake_bb_js.ts | 11 +++++--- .../src/verifier/queued_chonk_verifier.ts | 10 ++++--- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index 598401a3a99..944b38df871 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -19,14 +19,14 @@ describe('BBJsFactory pool', () => { const verify = (instance: BBJsApi) => instance.verifyChonkProof([], new Uint8Array()); const verified = { verified: true, durationMs: 1 }; - // The fake spawns settle within microtasks, so one macrotask lets every pending spawn and waiting borrow progress. + // Runs the pending microtasks, such as a released fake spawn or a borrow taking an idle instance. const settle = () => new Promise(resolve => setImmediate(resolve)); afterEach(async () => { await factory.destroy(); }); - it('does not return a borrowed instance whose bb died, and replaces it', async () => { + it('replaces a borrowed instance whose bb died', async () => { factory = new FakeBBJsFactory(1); { await using first = await factory.getInstance(); @@ -78,8 +78,6 @@ describe('BBJsFactory pool', () => { factory = new FakeBBJsFactory(1); const first = await factory.getInstance(); const waiting = factory.getInstance(); - // Let the second borrow start waiting on the full pool before the borrowed instance dies. - await settle(); factory.created[0].kill(); await first[Symbol.asyncDispose](); @@ -150,6 +148,23 @@ describe('BBJsFactory pool', () => { expect(factory.created[0].destroyCount).toBe(1); }); + it('destroys a dead instance that a borrow dropped before maintenance could', async () => { + // Maintenance runs only once, when the pool starts. + factory = new FakeBBJsFactory(1, 60_000); + { + await using _first = await factory.getInstance(); + factory.created[0].kill(); + } + const borrowFails = expect(factory.getInstance()).rejects.toThrow('destroyed while waiting'); + // Let the borrow take the dead instance and drop it. + await settle(); + + await factory.destroy(); + await borrowFails; + expect(factory.created).toHaveLength(1); + expect(factory.created[0].destroyCount).toBe(1); + }); + it('destroys every instance once when destroyed after replacing a dead idle one', async () => { factory = new FakeBBJsFactory(2); { diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 73ab42a511c..c186decda25 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -291,7 +291,7 @@ export class BBJsFactory { /** Idle pooled instances, created by the first `getInstance()` call when poolSize is set. May hold dead ones. */ private pool?: FifoMemoryQueue; - /** Every pooled instance, idle or borrowed, that has not been destroyed. */ + /** Every pooled instance that has not been destroyed, whether idle, borrowed, or dead and waiting to be destroyed. */ private members: BBJsApi[] = []; /** Pooled instances being spawned. */ private spawning = 0; @@ -335,15 +335,15 @@ export class BBJsFactory { throw new Error('BBJsFactory was destroyed while waiting for an instance'); } if (instance.isAlive()) { - return this.makeBorrowed(instance, pool); + return this.makeBorrowed(instance); } - // Dropped from the idle queue; pool maintenance destroys and replaces it. + // Dropped; pool maintenance or destroy() destroys it. } } /** - * Tear down all pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call - * instances are destroyed by their own dispose callbacks). Instances currently held by an + * Tear down the idle and dead pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call + * instances are destroyed by their own dispose callbacks). Live instances currently held by an * in-flight pooled borrow are destroyed by their dispose callback when released. Does not wait for a pooled instance * being spawned, which is destroyed when its spawn completes. */ @@ -366,8 +366,9 @@ export class BBJsFactory { } } pool.cancel(); + const dead = this.members.filter(member => !member.isAlive()); // Aggregate teardown failures so a single bb child that fails to shut down doesn't mask others. - const results = await Promise.allSettled(idle.map(item => this.retire(item))); + const results = await Promise.allSettled([...idle, ...dead].map(item => this.retire(item))); const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); if (errors.length > 0) { throw new AggregateError(errors, `BBJsFactory.destroy: ${errors.length} bb instance(s) failed to shut down`); @@ -463,16 +464,16 @@ export class BBJsFactory { } /** - * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool if it is alive, or destroys it if the - * factory was destroyed in the meantime. A dead instance is not returned, and pool maintenance destroys and replaces - * it. Destroy errors are propagated. + * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool (or destroys it + * if the factory was destroyed in the meantime). Destroy errors are propagated. */ - private makeBorrowed(instance: BBJsApi, pool: FifoMemoryQueue): BBJsApi & AsyncDisposable { + private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { return this.makeDisposable(instance, async () => { - if (this.destroyed) { - await this.retire(instance); - } else if (instance.isAlive()) { + const pool = this.pool; + if (pool && !this.destroyed) { pool.put(instance); + } else { + await this.retire(instance); } }); } diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts index 533245ac9d8..d2cdfa82128 100644 --- a/yarn-project/bb-prover/src/test/fake_bb_js.ts +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -92,11 +92,16 @@ type PlannedSpawn = { export class FakeBBJsFactory extends BBJsFactory { /** Every instance created, in creation order. */ public readonly created: FakeBBJsInstance[] = []; - protected override readonly maintenanceIntervalMs = 5; private readonly plan: PlannedSpawn[] = []; - /** @param poolSize - Pooled instances to keep; when omitted, every borrow creates a fresh instance. */ - constructor(poolSize?: number) { + /** + * @param poolSize - Pooled instances to keep; when omitted, every borrow creates a fresh instance. + * @param maintenanceIntervalMs - How often pool maintenance runs after its first run. + */ + constructor( + poolSize?: number, + protected override readonly maintenanceIntervalMs = 5, + ) { super('/unused/bb', { poolSize }); } diff --git a/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts b/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts index 71a1afe0776..7d1522be890 100644 --- a/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/queued_chonk_verifier.ts @@ -102,8 +102,12 @@ export class QueuedIVCVerifier implements ClientProtocolCircuitVerifier { } async stop(): Promise { - // Stopped first so that verifications waiting for the verifier fail, rather than keep the queue from draining. - await this.verifier.stop(); - await this.queue.end(); + // Stopped first so that verifications waiting for a bb instance fail rather than keep the queue from draining. Queued + // verifications that have not started yet fail too. + try { + await this.verifier.stop(); + } finally { + await this.queue.end(); + } } } From 0c55cd3f3bc924464aa4f363e9c6ba05cc6e7e9e Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 12:02:11 +0000 Subject: [PATCH 09/19] test(bb-prover): drop a pool test that the failed-spawn waiting test covers --- yarn-project/bb-prover/src/bb/bb_js_backend.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index 944b38df871..922941483d6 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -74,18 +74,6 @@ describe('BBJsFactory pool', () => { expect(failures).toBe(1); }); - it('hands the replacement for a dead borrowed instance to a borrower waiting for it', async () => { - factory = new FakeBBJsFactory(1); - const first = await factory.getInstance(); - const waiting = factory.getInstance(); - factory.created[0].kill(); - await first[Symbol.asyncDispose](); - - await using second = await waiting; - await expect(verify(second)).resolves.toEqual(verified); - expect(factory.created).toHaveLength(2); - }); - it('keeps a borrower waiting while replacements fail to spawn, and hands it the first one that starts', async () => { factory = new FakeBBJsFactory(1); const first = await factory.getInstance(); From d644fd1178d5ae62ee5f05ae904448c1b16b87ca Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 12:14:02 +0000 Subject: [PATCH 10/19] refactor(bb-prover): track borrowed pooled bb instances with a counter instead of a member list The idle queue is the only record of idle instances. Pool maintenance sweeps it, destroying dead instances and putting the live ones back, and spawns until idle, borrowed and spawning instances reach poolSize. A borrow destroys a dead instance it takes. Each instance is idle, borrowed or destroyed, so destroy() tears down the idle queue as on main. --- .../bb-prover/src/bb/bb_js_backend.test.ts | 13 +++-- .../bb-prover/src/bb/bb_js_backend.ts | 58 +++++++++---------- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index 922941483d6..f5281508324 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -109,17 +109,18 @@ describe('BBJsFactory pool', () => { expect(factory.created).toHaveLength(2); }); - it('does not spawn past poolSize while a spawn is in flight', async () => { - factory = new FakeBBJsFactory(1); + it('does not spawn past poolSize while instances are borrowed or being spawned', async () => { + factory = new FakeBBJsFactory(2); const spawned = promiseWithResolvers(); + factory.planNextInstance([]); factory.planNextInstance([], spawned.promise); - const borrowing = factory.getInstance(); - // Several maintenance runs happen while the spawn is in flight. + await using _borrowed = await factory.getInstance(); + // Several maintenance runs happen while one instance is borrowed and the other is being spawned. await sleep(50); spawned.resolve(); + await settle(); - await using _instance = await borrowing; - expect(factory.created).toHaveLength(1); + expect(factory.created).toHaveLength(2); }); it('does not wait for a spawn in flight when destroyed, and destroys its instance once it arrives', async () => { diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index c186decda25..2e958dcbc0d 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -289,10 +289,13 @@ export class BBJsFactory { private readonly threads?: number; private readonly debugDir?: string; - /** Idle pooled instances, created by the first `getInstance()` call when poolSize is set. May hold dead ones. */ + /** + * Idle pooled instances, created by the first `getInstance()` call when poolSize is set. May hold dead ones until pool + * maintenance or a borrow finds them. + */ private pool?: FifoMemoryQueue; - /** Every pooled instance that has not been destroyed, whether idle, borrowed, or dead and waiting to be destroyed. */ - private members: BBJsApi[] = []; + /** Pooled instances currently borrowed. */ + private borrowed = 0; /** Pooled instances being spawned. */ private spawning = 0; /** Runs {@link maintainPool} every `maintenanceIntervalMs` once the pool exists. */ @@ -337,13 +340,13 @@ export class BBJsFactory { if (instance.isAlive()) { return this.makeBorrowed(instance); } - // Dropped; pool maintenance or destroy() destroys it. + this.destroyDead(instance); } } /** - * Tear down the idle and dead pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call - * instances are destroyed by their own dispose callbacks). Live instances currently held by an + * Tear down all pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call + * instances are destroyed by their own dispose callbacks). Instances currently held by an * in-flight pooled borrow are destroyed by their dispose callback when released. Does not wait for a pooled instance * being spawned, which is destroyed when its spawn completes. */ @@ -357,7 +360,6 @@ export class BBJsFactory { if (!pool) { return; } - await this.maintenance?.stop(); const idle: BBJsApi[] = []; while (pool.length() > 0) { const item = pool.getImmediate(); @@ -366,9 +368,9 @@ export class BBJsFactory { } } pool.cancel(); - const dead = this.members.filter(member => !member.isAlive()); + await this.maintenance?.stop(); // Aggregate teardown failures so a single bb child that fails to shut down doesn't mask others. - const results = await Promise.allSettled([...idle, ...dead].map(item => this.retire(item))); + const results = await Promise.allSettled(idle.map(item => item.destroy())); const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); if (errors.length > 0) { throw new AggregateError(errors, `BBJsFactory.destroy: ${errors.length} bb instance(s) failed to shut down`); @@ -397,18 +399,16 @@ export class BBJsFactory { * wait for the spawns, so that a slow spawn delays neither the next run nor `destroy()`. */ private maintainPool(pool: FifoMemoryQueue): void { - const dead = this.members.filter(member => !member.isAlive()); - if (dead.length > 0) { - this.logger?.warn('Replacing pooled bb instances whose process died', { - poolSize: this.poolSize, - dead: dead.length, - }); - } - for (const instance of dead) { - // bb is already gone, so a teardown error is not actionable. - void this.retire(instance).catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); + // A queue that holds instances has no waiters, so the live instances taken out go back in the same order. + for (let idle = pool.length(); idle > 0; idle--) { + const instance = pool.getImmediate()!; + if (instance.isAlive()) { + pool.put(instance); + } else { + this.destroyDead(instance); + } } - for (let missing = this.poolSize! - this.members.length - this.spawning; missing > 0; missing--) { + for (let missing = this.poolSize! - pool.length() - this.borrowed - this.spawning; missing > 0; missing--) { void this.spawnMember(pool); } } @@ -431,18 +431,14 @@ export class BBJsFactory { .catch(err => this.logger?.warn('Failed to destroy a bb instance spawned during shutdown', { err })); return; } - this.members.push(instance); pool.put(instance); } - /** Removes a pooled instance from the pool's members and destroys it, unless it was already removed. */ - private async retire(instance: BBJsApi): Promise { - const index = this.members.indexOf(instance); - if (index === -1) { - return; - } - this.members.splice(index, 1); - await instance.destroy(); + /** Destroys a dead pooled instance that the caller took out of the pool, so that pool maintenance replaces it. */ + private destroyDead(instance: BBJsApi): void { + this.logger?.warn('Destroying a pooled bb instance whose process died', { poolSize: this.poolSize }); + // bb is already gone, so a teardown error is not actionable. + void instance.destroy().catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); } /** Wrap the instance in a debug wrapper if debugDir is configured. */ @@ -468,12 +464,14 @@ export class BBJsFactory { * if the factory was destroyed in the meantime). Destroy errors are propagated. */ private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { + this.borrowed++; return this.makeDisposable(instance, async () => { + this.borrowed--; const pool = this.pool; if (pool && !this.destroyed) { pool.put(instance); } else { - await this.retire(instance); + await instance.destroy(); } }); } From a25e7fc60a623c63f0af88e681571a247f5f5cd0 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 28 Sep 2026 12:18:43 +0000 Subject: [PATCH 11/19] refactor(bb-prover): count pooled bb instances with a single counter The count covers idle, borrowed and spawning instances: a spawn adds one, and a failed spawn or a destroyed dead instance removes one. Pool maintenance spawns up to poolSize from it, and the dispose code is main's again. --- .../bb-prover/src/bb/bb_js_backend.test.ts | 29 +++++-------------- .../bb-prover/src/bb/bb_js_backend.ts | 20 +++++-------- yarn-project/bb-prover/src/test/fake_bb_js.ts | 11 ++----- 3 files changed, 18 insertions(+), 42 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index f5281508324..beaa529cc9d 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -19,7 +19,7 @@ describe('BBJsFactory pool', () => { const verify = (instance: BBJsApi) => instance.verifyChonkProof([], new Uint8Array()); const verified = { verified: true, durationMs: 1 }; - // Runs the pending microtasks, such as a released fake spawn or a borrow taking an idle instance. + // Runs the pending microtasks, such as those of a released fake spawn. const settle = () => new Promise(resolve => setImmediate(resolve)); afterEach(async () => { @@ -87,6 +87,7 @@ describe('BBJsFactory pool', () => { await using second = await waiting; await expect(verify(second)).resolves.toEqual(verified); expect(factory.created).toHaveLength(2); + expect(factory.created[0].destroyCount).toBe(1); }); it('waits for the first instance when bb cannot start yet', async () => { @@ -109,18 +110,19 @@ describe('BBJsFactory pool', () => { expect(factory.created).toHaveLength(2); }); - it('does not spawn past poolSize while instances are borrowed or being spawned', async () => { - factory = new FakeBBJsFactory(2); + it('does not spawn past poolSize while instances are idle, borrowed or being spawned', async () => { + factory = new FakeBBJsFactory(3); const spawned = promiseWithResolvers(); factory.planNextInstance([]); + factory.planNextInstance([]); factory.planNextInstance([], spawned.promise); await using _borrowed = await factory.getInstance(); - // Several maintenance runs happen while one instance is borrowed and the other is being spawned. + // Several maintenance runs happen while one instance is borrowed, one is idle and one is being spawned. await sleep(50); spawned.resolve(); await settle(); - expect(factory.created).toHaveLength(2); + expect(factory.created).toHaveLength(3); }); it('does not wait for a spawn in flight when destroyed, and destroys its instance once it arrives', async () => { @@ -137,23 +139,6 @@ describe('BBJsFactory pool', () => { expect(factory.created[0].destroyCount).toBe(1); }); - it('destroys a dead instance that a borrow dropped before maintenance could', async () => { - // Maintenance runs only once, when the pool starts. - factory = new FakeBBJsFactory(1, 60_000); - { - await using _first = await factory.getInstance(); - factory.created[0].kill(); - } - const borrowFails = expect(factory.getInstance()).rejects.toThrow('destroyed while waiting'); - // Let the borrow take the dead instance and drop it. - await settle(); - - await factory.destroy(); - await borrowFails; - expect(factory.created).toHaveLength(1); - expect(factory.created[0].destroyCount).toBe(1); - }); - it('destroys every instance once when destroyed after replacing a dead idle one', async () => { factory = new FakeBBJsFactory(2); { diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 2e958dcbc0d..e1d9293b773 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -294,10 +294,8 @@ export class BBJsFactory { * maintenance or a borrow finds them. */ private pool?: FifoMemoryQueue; - /** Pooled instances currently borrowed. */ - private borrowed = 0; - /** Pooled instances being spawned. */ - private spawning = 0; + /** Pooled instances that are idle, borrowed or being spawned. */ + private instanceCount = 0; /** Runs {@link maintainPool} every `maintenanceIntervalMs` once the pool exists. */ private maintenance?: RunningPromise; private destroyed = false; @@ -395,8 +393,8 @@ export class BBJsFactory { } /** - * Destroys the pooled instances whose bb process died and starts spawns until the pool is back at `poolSize`. Does not - * wait for the spawns, so that a slow spawn delays neither the next run nor `destroy()`. + * Destroys the idle pooled instances whose bb process died and starts spawns until the pool is back at `poolSize`. Does + * not wait for the spawns, so that a slow spawn delays neither the next run nor `destroy()`. */ private maintainPool(pool: FifoMemoryQueue): void { // A queue that holds instances has no waiters, so the live instances taken out go back in the same order. @@ -408,22 +406,21 @@ export class BBJsFactory { this.destroyDead(instance); } } - for (let missing = this.poolSize! - pool.length() - this.borrowed - this.spawning; missing > 0; missing--) { + for (let missing = this.poolSize! - this.instanceCount; missing > 0; missing--) { void this.spawnMember(pool); } } /** Spawns a pooled instance and queues it as idle, or destroys it if the factory was destroyed during the spawn. */ private async spawnMember(pool: FifoMemoryQueue): Promise { - this.spawning++; + this.instanceCount++; let instance: BBJsApi; try { instance = await this.createInstance(); } catch (err) { + this.instanceCount--; this.logger?.warn('Failed to spawn a pooled bb instance', { poolSize: this.poolSize, err }); return; - } finally { - this.spawning--; } if (this.destroyed) { await instance @@ -436,6 +433,7 @@ export class BBJsFactory { /** Destroys a dead pooled instance that the caller took out of the pool, so that pool maintenance replaces it. */ private destroyDead(instance: BBJsApi): void { + this.instanceCount--; this.logger?.warn('Destroying a pooled bb instance whose process died', { poolSize: this.poolSize }); // bb is already gone, so a teardown error is not actionable. void instance.destroy().catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); @@ -464,9 +462,7 @@ export class BBJsFactory { * if the factory was destroyed in the meantime). Destroy errors are propagated. */ private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { - this.borrowed++; return this.makeDisposable(instance, async () => { - this.borrowed--; const pool = this.pool; if (pool && !this.destroyed) { pool.put(instance); diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts index d2cdfa82128..533245ac9d8 100644 --- a/yarn-project/bb-prover/src/test/fake_bb_js.ts +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -92,16 +92,11 @@ type PlannedSpawn = { export class FakeBBJsFactory extends BBJsFactory { /** Every instance created, in creation order. */ public readonly created: FakeBBJsInstance[] = []; + protected override readonly maintenanceIntervalMs = 5; private readonly plan: PlannedSpawn[] = []; - /** - * @param poolSize - Pooled instances to keep; when omitted, every borrow creates a fresh instance. - * @param maintenanceIntervalMs - How often pool maintenance runs after its first run. - */ - constructor( - poolSize?: number, - protected override readonly maintenanceIntervalMs = 5, - ) { + /** @param poolSize - Pooled instances to keep; when omitted, every borrow creates a fresh instance. */ + constructor(poolSize?: number) { super('/unused/bb', { poolSize }); } From e15069b12a17f1bc438dd12a58fb58d856a80684 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:03:40 +0000 Subject: [PATCH 12/19] refactor(bb-prover): let the bb instance replace its own process, not the pool Keeps the verifier half of this branch and replaces the pool half with one option. A pooled instance now spawns with bb.js's `respawn`, so an instance whose bb process dies replaces it and the next borrower gets a working one. The pool needs no liveness check, no eviction and no maintenance loop: returning an instance unconditionally is correct again, exactly as it already is for the AVM simulator pool. The verification retry keys off the failure rather than a liveness query. bb.js marks an environmental failure with `retry: true`, feature-detected rather than imported, so the same check works across the bb.js and ipc-runtime package boundaries. Asking an instance whether it is still alive can only ever be a guess, since the process can die between the answer and the next call, and it would not distinguish a dead helper from a bad proof, which is what makes the node report one as the other. The double models the same thing: a death fails the call in flight, retryably, and leaves the instance usable, because a replacement process serves the next call. Needs a bb.js release carrying aztec-packages#25548, which adds `respawn` and the `retry` property. Until the pin moves this does not build, as the branch it adjusts did not. The bb.js side is tested there, including against a real bb. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- .../bb-prover/src/bb/bb_js_backend.test.ts | 144 +---------------- .../bb-prover/src/bb/bb_js_backend.ts | 150 +++++++----------- yarn-project/bb-prover/src/bb/bb_js_debug.ts | 4 - yarn-project/bb-prover/src/test/fake_bb_js.ts | 34 ++-- .../src/verifier/bb_verifier.test.ts | 11 +- .../bb-prover/src/verifier/bb_verifier.ts | 20 ++- 6 files changed, 96 insertions(+), 267 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index beaa529cc9d..c60fc88b29c 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -1,10 +1,6 @@ -import { promiseWithResolvers } from '@aztec-labs/foundation/promise'; -import { retryUntil } from '@aztec-labs/foundation/retry'; -import { sleep } from '@aztec-labs/foundation/sleep'; import { ProvingError } from '@aztec-labs/stdlib/errors'; -import { FakeBBJsFactory } from '../test/fake_bb_js.js'; -import { type BBJsApi, BBJsInstance } from './bb_js_backend.js'; +import { BBJsInstance } from './bb_js_backend.js'; describe('BBJsInstance', () => { it('wraps bb startup failures as a retryable ProvingError', async () => { @@ -13,141 +9,3 @@ describe('BBJsInstance', () => { expect(err.retry).toBe(true); }); }); - -describe('BBJsFactory pool', () => { - let factory: FakeBBJsFactory; - - const verify = (instance: BBJsApi) => instance.verifyChonkProof([], new Uint8Array()); - const verified = { verified: true, durationMs: 1 }; - // Runs the pending microtasks, such as those of a released fake spawn. - const settle = () => new Promise(resolve => setImmediate(resolve)); - - afterEach(async () => { - await factory.destroy(); - }); - - it('replaces a borrowed instance whose bb died', async () => { - factory = new FakeBBJsFactory(1); - { - await using first = await factory.getInstance(); - factory.created[0].kill(); - await expect(verify(first)).rejects.toThrow('Socket not connected'); - } - - await using second = await factory.getInstance(); - await expect(verify(second)).resolves.toEqual(verified); - expect(factory.created).toHaveLength(2); - expect(factory.created[0].destroyCount).toBe(1); - expect(factory.created[1].chonkVerifyCalls).toBe(1); - }); - - it('skips an idle instance whose bb died and replaces it', async () => { - factory = new FakeBBJsFactory(2); - { - await using _warmup = await factory.getInstance(); - } - const [warm, other] = [factory.created[0], factory.created[1]]; - warm.kill(); - - await using a = await factory.getInstance(); - await using b = await factory.getInstance(); - await expect(verify(a)).resolves.toEqual(verified); - await expect(verify(b)).resolves.toEqual(verified); - expect(factory.created).toHaveLength(3); - expect(warm.chonkVerifyCalls).toBe(0); - expect(warm.destroyCount).toBe(1); - expect(other.chonkVerifyCalls).toBe(1); - }); - - it('loses one call to a bb that dies, not a share of the calls after it', async () => { - factory = new FakeBBJsFactory(2); - factory.planNextInstance(['die']); - let failures = 0; - for (let i = 0; i < 40; i++) { - await using instance = await factory.getInstance(); - try { - await verify(instance); - } catch { - failures++; - } - } - expect(failures).toBe(1); - }); - - it('keeps a borrower waiting while replacements fail to spawn, and hands it the first one that starts', async () => { - factory = new FakeBBJsFactory(1); - const first = await factory.getInstance(); - const waiting = factory.getInstance(); - factory.created[0].kill(); - for (let i = 0; i < 3; i++) { - factory.planNextInstance(new Error('spawn failed')); - } - await first[Symbol.asyncDispose](); - - await using second = await waiting; - await expect(verify(second)).resolves.toEqual(verified); - expect(factory.created).toHaveLength(2); - expect(factory.created[0].destroyCount).toBe(1); - }); - - it('waits for the first instance when bb cannot start yet', async () => { - factory = new FakeBBJsFactory(1); - factory.planNextInstance(new Error('spawn failed')); - factory.planNextInstance(new Error('spawn failed')); - - await using instance = await factory.getInstance(); - await expect(verify(instance)).resolves.toEqual(verified); - }); - - it('keeps the instances that started and spawns the ones that failed later', async () => { - factory = new FakeBBJsFactory(2); - factory.planNextInstance(new Error('spawn failed')); - - await using a = await factory.getInstance(); - await using b = await factory.getInstance(); - await expect(verify(a)).resolves.toEqual(verified); - await expect(verify(b)).resolves.toEqual(verified); - expect(factory.created).toHaveLength(2); - }); - - it('does not spawn past poolSize while instances are idle, borrowed or being spawned', async () => { - factory = new FakeBBJsFactory(3); - const spawned = promiseWithResolvers(); - factory.planNextInstance([]); - factory.planNextInstance([]); - factory.planNextInstance([], spawned.promise); - await using _borrowed = await factory.getInstance(); - // Several maintenance runs happen while one instance is borrowed, one is idle and one is being spawned. - await sleep(50); - spawned.resolve(); - await settle(); - - expect(factory.created).toHaveLength(3); - }); - - it('does not wait for a spawn in flight when destroyed, and destroys its instance once it arrives', async () => { - factory = new FakeBBJsFactory(1); - const spawned = promiseWithResolvers(); - factory.planNextInstance([], spawned.promise); - const borrowFails = expect(factory.getInstance()).rejects.toThrow('destroyed while waiting'); - - await factory.destroy(); - await borrowFails; - spawned.resolve(); - await settle(); - expect(factory.created).toHaveLength(1); - expect(factory.created[0].destroyCount).toBe(1); - }); - - it('destroys every instance once when destroyed after replacing a dead idle one', async () => { - factory = new FakeBBJsFactory(2); - { - await using _warmup = await factory.getInstance(); - } - factory.created[1].kill(); - await retryUntil(() => factory.created.length === 3, 'replacement of the dead instance', 5, 0.001); - - await factory.destroy(); - expect(factory.created.map(instance => instance.destroyCount)).toEqual([1, 1, 1]); - }); -}); diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index e1d9293b773..28fdba167e8 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -1,7 +1,6 @@ import { type AvmStat, type BackendOptions, BackendType, Barretenberg } from '@aztec-foundation/bb.js'; import type { LogFn, Logger } from '@aztec-labs/foundation/log'; -import { RunningPromise } from '@aztec-labs/foundation/promise'; import { FifoMemoryQueue } from '@aztec-labs/foundation/queue'; import { Timer } from '@aztec-labs/foundation/timer'; import { ProvingError } from '@aztec-labs/stdlib/errors'; @@ -67,11 +66,6 @@ export interface BBJsApi { verifyAvmProof(proof: Uint8Array[], publicInputs: Uint8Array): Promise<{ verified: boolean; durationMs: number }>; /** Check the AVM circuit from serialized inputs. Returns pass/fail and per-stage timings. */ checkAvmCircuit(inputs: Uint8Array): Promise<{ passed: boolean; stats: AvmStat[]; durationMs: number }>; - /** - * Whether the bb process behind this instance is running and connected. An instance that is not alive never recovers, - * and every later call on it fails. - */ - isAlive(): boolean; destroy(): Promise; } @@ -82,12 +76,19 @@ export interface BBJsApi { export class BBJsInstance implements BBJsApi { private constructor(private api: Barretenberg) {} - /** Creates a new Barretenberg instance connected to a fresh bb process. */ - static async create(bbPath: string, logger?: LogFn, threads?: number): Promise { + /** + * Creates a new Barretenberg instance connected to a fresh bb process. + * + * `respawn` lets the instance replace its bb process when it dies, so a long-lived instance stays + * usable instead of failing every later call. Only safe where the instance holds no state between + * calls, since a replacement has no Chonk accumulation and no batch-verifier session. + */ + static async create(bbPath: string, logger?: LogFn, threads?: number, respawn?: boolean): Promise { const options: BackendOptions = { bbPath, backend: BackendType.NativeUnixSocket, logger, + respawn, }; if (threads !== undefined) { options.threads = threads; @@ -244,10 +245,6 @@ export class BBJsInstance implements BBJsApi { return { passed: result.passed, stats: result.stats, durationMs: timer.ms() }; } - isAlive(): boolean { - return this.api.isAlive(); - } - /** Destroy this instance and kill the underlying bb process. */ async destroy(): Promise { await this.api.destroy(); @@ -272,10 +269,6 @@ export interface BBJsFactoryOptions { * set of long-lived bb processes that are reused across calls — useful when the per-call * bb startup cost dominates the workload (e.g. high-rate IVC verification). * - * A pooled instance whose bb process died is never handed out again. Every `maintenanceIntervalMs` the pool destroys the - * dead instances and spawns new ones until it is back at `poolSize`, retrying failed spawns on the next run. A borrower - * waits for a live instance however long that takes, as it does while every instance is busy. - * * Idiomatic usage: * ``` * await using inst = await factory.getInstance(); @@ -289,18 +282,11 @@ export class BBJsFactory { private readonly threads?: number; private readonly debugDir?: string; - /** - * Idle pooled instances, created by the first `getInstance()` call when poolSize is set. May hold dead ones until pool - * maintenance or a borrow finds them. - */ + /** Available pooled instances when poolSize is set; otherwise undefined. */ private pool?: FifoMemoryQueue; - /** Pooled instances that are idle, borrowed or being spawned. */ - private instanceCount = 0; - /** Runs {@link maintainPool} every `maintenanceIntervalMs` once the pool exists. */ - private maintenance?: RunningPromise; + /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ + private initPromise?: Promise; private destroyed = false; - /** How often the pool destroys dead instances and spawns the missing ones. */ - protected readonly maintenanceIntervalMs: number = 1000; constructor( private bbPath: string, @@ -317,8 +303,8 @@ export class BBJsFactory { /** * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. - * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: waits for a live instance, borrows it, - * and returns it to the pool on dispose. Throws once the factory is destroyed, including while waiting. + * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows from + * the pool and returns to it on dispose. */ async getInstance(): Promise { if (this.destroyed) { @@ -329,24 +315,25 @@ export class BBJsFactory { const instance = await this.createInstance(); return this.makeOwned(instance); } - const pool = this.startPool(); - for (;;) { - const instance = await pool.get(); - if (!instance) { - throw new Error('BBJsFactory was destroyed while waiting for an instance'); - } - if (instance.isAlive()) { - return this.makeBorrowed(instance); - } - this.destroyDead(instance); + if (!this.initPromise) { + this.initPromise = this.initPool(); + } + await this.initPromise; + const pool = this.pool; + if (!pool) { + throw new Error('BBJsFactory has been destroyed'); } + const instance = await pool.get(); + if (!instance) { + throw new Error('BBJsFactory was destroyed while waiting for an instance'); + } + return this.makeBorrowed(instance); } /** * Tear down all pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call * instances are destroyed by their own dispose callbacks). Instances currently held by an - * in-flight pooled borrow are destroyed by their dispose callback when released. Does not wait for a pooled instance - * being spawned, which is destroyed when its spawn completes. + * in-flight pooled borrow are destroyed by their dispose callback when released. */ async destroy(): Promise { if (this.destroyed) { @@ -366,7 +353,6 @@ export class BBJsFactory { } } pool.cancel(); - await this.maintenance?.stop(); // Aggregate teardown failures so a single bb child that fails to shut down doesn't mask others. const results = await Promise.allSettled(idle.map(item => item.destroy())); const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); @@ -375,68 +361,42 @@ export class BBJsFactory { } } - protected async createInstance(): Promise { - const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; - const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads); - return this.maybeWrapDebug(raw); - } - - /** Creates the idle queue and starts pool maintenance, whose first run spawns the pool. */ - private startPool(): FifoMemoryQueue { - if (!this.pool) { - const pool = new FifoMemoryQueue(); - this.pool = pool; - this.maintenance = new RunningPromise(() => this.maintainPool(pool), this.logger, this.maintenanceIntervalMs); - this.maintenance.start(); - } - return this.pool; - } - - /** - * Destroys the idle pooled instances whose bb process died and starts spawns until the pool is back at `poolSize`. Does - * not wait for the spawns, so that a slow spawn delays neither the next run nor `destroy()`. - */ - private maintainPool(pool: FifoMemoryQueue): void { - // A queue that holds instances has no waiters, so the live instances taken out go back in the same order. - for (let idle = pool.length(); idle > 0; idle--) { - const instance = pool.getImmediate()!; - if (instance.isAlive()) { - pool.put(instance); + private async initPool(): Promise { + // Use allSettled so that if any createInstance() rejects we can destroy the rest instead of + // leaking bb child processes whose creation succeeded. + const results = await Promise.allSettled(Array.from({ length: this.poolSize! }, () => this.createInstance())); + const items: BBJsApi[] = []; + const errors: unknown[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + items.push(result.value); } else { - this.destroyDead(instance); + errors.push(result.reason); } } - for (let missing = this.poolSize! - this.instanceCount; missing > 0; missing--) { - void this.spawnMember(pool); - } - } - - /** Spawns a pooled instance and queues it as idle, or destroys it if the factory was destroyed during the spawn. */ - private async spawnMember(pool: FifoMemoryQueue): Promise { - this.instanceCount++; - let instance: BBJsApi; - try { - instance = await this.createInstance(); - } catch (err) { - this.instanceCount--; - this.logger?.warn('Failed to spawn a pooled bb instance', { poolSize: this.poolSize, err }); + if (errors.length > 0 || this.destroyed) { + // Either creation failed or destroy() raced ahead — clean up everything we successfully spawned. + await Promise.all(items.map(item => item.destroy())); + if (errors.length > 0) { + throw errors[0]; + } return; } - if (this.destroyed) { - await instance - .destroy() - .catch(err => this.logger?.warn('Failed to destroy a bb instance spawned during shutdown', { err })); - return; + const pool = new FifoMemoryQueue(); + for (const item of items) { + pool.put(item); } - pool.put(instance); + this.pool = pool; } - /** Destroys a dead pooled instance that the caller took out of the pool, so that pool maintenance replaces it. */ - private destroyDead(instance: BBJsApi): void { - this.instanceCount--; - this.logger?.warn('Destroying a pooled bb instance whose process died', { poolSize: this.poolSize }); - // bb is already gone, so a teardown error is not actionable. - void instance.destroy().catch(err => this.logger?.warn('Failed to destroy a dead bb instance', { err })); + protected async createInstance(): Promise { + const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; + // A pooled instance outlives the call that borrowed it, so it replaces a bb process that dies + // under it and the next borrower gets a working one. Each verification stands alone, so a + // replacement has nothing to carry over. A fresh-per-call instance has nothing to heal. + const respawn = this.poolSize !== undefined; + const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads, respawn); + return this.maybeWrapDebug(raw); } /** Wrap the instance in a debug wrapper if debugDir is configured. */ diff --git a/yarn-project/bb-prover/src/bb/bb_js_debug.ts b/yarn-project/bb-prover/src/bb/bb_js_debug.ts index 13e7e836b02..c76d176caed 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_debug.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_debug.ts @@ -221,10 +221,6 @@ export class DebugBBJsInstance implements BBJsApi { return this.inner.generateContract(verificationKey); } - isAlive(): boolean { - return this.inner.isAlive(); - } - destroy(): Promise { return this.inner.destroy(); } diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts index 533245ac9d8..280ec61c132 100644 --- a/yarn-project/bb-prover/src/test/fake_bb_js.ts +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -9,28 +9,30 @@ function notImplemented(): Promise { return Promise.reject(new Error('Not implemented by FakeBBJsInstance')); } -/** A {@link BBJsApi} double whose bb process can die. Only `verifyChonkProof` is implemented. */ +/** An error shaped like the one bb.js raises when the bb process died: retrying may help. */ +function retryable(message: string): Error { + return Object.assign(new Error(message), { retry: true }); +} + +/** + * A {@link BBJsApi} double whose bb process can die. + * + * A pooled instance is created with respawn, so a death fails only the call that was in flight and + * the next call is served by a replacement process. The double behaves the same way: `die` rejects + * once, retryably, and leaves the instance usable. Only `verifyChonkProof` is implemented. + */ export class FakeBBJsInstance implements BBJsApi { public destroyCount = 0; public chonkVerifyCalls = 0; - private alive = true; + private destroyed = false; /** @param outcomes - Answers to successive `verifyChonkProof` calls; `valid` once they run out. */ constructor(private readonly outcomes: FakeChonkVerifyOutcome[] = []) {} - /** Simulates the bb process dying: every later call fails as it does on a closed socket. */ - public kill(): void { - this.alive = false; - } - - public isAlive(): boolean { - return this.alive; - } - public verifyChonkProof(): Promise<{ verified: boolean; durationMs: number }> { this.chonkVerifyCalls++; - if (!this.alive) { - return Promise.reject(new Error('Socket not connected')); + if (this.destroyed) { + return Promise.reject(new Error('Backend connection closed')); } switch (this.outcomes.shift() ?? 'valid') { case 'valid': @@ -40,13 +42,12 @@ export class FakeBBJsInstance implements BBJsApi { case 'bb-error': return Promise.reject(new Error('bb rejected the proof input')); case 'die': - this.kill(); - return Promise.reject(new Error('Socket connection ended unexpectedly')); + return Promise.reject(retryable('Socket connection ended unexpectedly')); } } public destroy(): Promise { - this.alive = false; + this.destroyed = true; this.destroyCount++; return Promise.resolve(); } @@ -92,7 +93,6 @@ type PlannedSpawn = { export class FakeBBJsFactory extends BBJsFactory { /** Every instance created, in creation order. */ public readonly created: FakeBBJsInstance[] = []; - protected override readonly maintenanceIntervalMs = 5; private readonly plan: PlannedSpawn[] = []; /** @param poolSize - Pooled instances to keep; when omitted, every borrow creates a fresh instance. */ diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts index 97169f0d624..eb95c40825a 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -55,16 +55,17 @@ describe('BBCircuitVerifier', () => { expect(factory.created).toHaveLength(1); }); - it('retries on a replacement instance when bb dies during verification', async () => { + it('retries after bb dies during verification, on the replacement process', async () => { factory.planNextInstance(['die']); await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); - expect(factory.created).toHaveLength(2); - expect(factory.created[0].destroyCount).toBe(1); + // The instance replaced its own bb process, so the pool neither grew nor lost a member. + expect(factory.created).toHaveLength(1); + expect(factory.created[0].chonkVerifyCalls).toBe(2); + expect(factory.created[0].destroyCount).toBe(0); }); it('reports the verifier unavailable, not the proof invalid, when bb dies on every attempt', async () => { - factory.planNextInstance(['die']); - factory.planNextInstance(['die']); + factory.planNextInstance(['die', 'die']); await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); }); diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index 42cac5c4a56..80e08044432 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -18,6 +18,17 @@ import { type BBJsApi, BBJsFactory } from '../bb/bb_js_backend.js'; import type { BBConfig } from '../config.js'; import { getUltraHonkFlavorForCircuit } from '../honk.js'; +/** + * Whether a failure was environmental, and so may be retried: the bb process died, its connection broke, or it could + * not be started. + * + * The bare `retry` property is the contract, feature-detected rather than imported, so it holds across the bb.js and + * ipc-runtime package boundaries alike. An error without it is the verification's own verdict. + */ +function isRetryableFailure(err: unknown): boolean { + return err instanceof Error && (err as Error & { retry?: unknown }).retry === true; +} + /** Thrown when no live bb process could check a proof, so the proof was neither accepted nor rejected. */ export class ProofVerifierUnavailableError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -144,8 +155,11 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { } /** - * Runs a Chonk verification on a pooled bb instance. A call that fails because the instance's bb died is retried on - * another instance; any other failure is rethrown. + * Runs a Chonk verification on a pooled bb instance. A call that failed for environmental reasons — its bb process + * died, or could not be started — is retried; any other failure is the verification's own verdict and is rethrown. + * + * The error says so itself, through the `retry` property bb.js sets. Asking the instance whether it is still alive + * would be a guess: the process can die between the answer and the next call. */ private async verifyChonkProofOnLiveInstance( fieldsWithPublicInputs: Uint8Array[], @@ -157,7 +171,7 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { try { return await instance.verifyChonkProof(fieldsWithPublicInputs, verificationKey); } catch (err) { - if (instance.isAlive()) { + if (!isRetryableFailure(err)) { throw err; } if (attempt >= BBCircuitVerifier.MAX_CHONK_VERIFY_ATTEMPTS) { From effd9ca79438ad3003f08430a2363a0e7f2d40a8 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:22:38 +0000 Subject: [PATCH 13/19] fix(bb-prover): honour the retry flag on the proving path too `ProvingError` was the only retryable failure the prover recognised, so a bb process that died mid-call surfaced as permanent and the job was not retried. Both the prover and the verifier now ask the same question of the error, and the classifier lives next to the backend that raises it rather than once per caller. `ProvingError.retry` still answers true, since the check is on the property rather than the class. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- yarn-project/bb-prover/src/bb/bb_js_backend.ts | 12 ++++++++++++ .../bb-prover/src/prover/server/bb_prover.ts | 6 +++--- yarn-project/bb-prover/src/verifier/bb_verifier.ts | 13 +------------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 28fdba167e8..01c62ec910f 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -69,6 +69,18 @@ export interface BBJsApi { destroy(): Promise; } +/** + * Whether a failure was environmental, and so may be retried: the bb process died, its connection + * broke, or it could not be started. + * + * The bare `retry` property is the contract, feature-detected rather than imported, so the same + * check holds for errors from bb.js, from ipc-runtime and from ProvingError alike. An error + * without it failed for a reason retrying cannot fix. + */ +export function isRetryableFailure(err: unknown): boolean { + return err instanceof Error && (err as Error & { retry?: unknown }).retry === true; +} + /** * Thin wrapper around a single Barretenberg instance. * Each instance spawns its own bb process via the NativeUnixSocket backend. diff --git a/yarn-project/bb-prover/src/prover/server/bb_prover.ts b/yarn-project/bb-prover/src/prover/server/bb_prover.ts index d5a03f7dde3..5eee2ecbf15 100644 --- a/yarn-project/bb-prover/src/prover/server/bb_prover.ts +++ b/yarn-project/bb-prover/src/prover/server/bb_prover.ts @@ -75,7 +75,7 @@ import { promises as fs } from 'fs'; import { ungzip } from 'pako'; import * as path from 'path'; -import { BBJsFactory, type BBJsProofResult } from '../../bb/bb_js_backend.js'; +import { BBJsFactory, type BBJsProofResult, isRetryableFailure } from '../../bb/bb_js_backend.js'; import type { ACVMConfig, BBConfig } from '../../config.js'; import { getUltraHonkFlavorForCircuit } from '../../honk.js'; import { ProverInstrumentation } from '../../instrumentation.js'; @@ -449,7 +449,7 @@ export class BBNativeRollupProver implements ServerCircuitProver { ); } catch (error) { // Preserve retryability of the underlying failure (e.g. a transient bb startup error). - const retry = error instanceof ProvingError && error.retry; + const retry = isRetryableFailure(error); throw new ProvingError(`Failed to generate proof for ${circuitType}: ${error}`, error, retry); } @@ -590,7 +590,7 @@ export class BBNativeRollupProver implements ServerCircuitProver { )); } catch (error) { // Preserve retryability of the underlying failure (e.g. a transient bb startup error). - const retry = error instanceof ProvingError && error.retry; + const retry = isRetryableFailure(error); throw new ProvingError(`Failed to verify proof for ${circuitType}: ${error}`, error, retry); } diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index 80e08044432..4c9c229b681 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -14,21 +14,10 @@ import { Tx } from '@aztec-labs/stdlib/tx'; import type { VerificationKeyData } from '@aztec-labs/stdlib/vks'; import { promises as fs } from 'fs'; -import { type BBJsApi, BBJsFactory } from '../bb/bb_js_backend.js'; +import { type BBJsApi, BBJsFactory, isRetryableFailure } from '../bb/bb_js_backend.js'; import type { BBConfig } from '../config.js'; import { getUltraHonkFlavorForCircuit } from '../honk.js'; -/** - * Whether a failure was environmental, and so may be retried: the bb process died, its connection broke, or it could - * not be started. - * - * The bare `retry` property is the contract, feature-detected rather than imported, so it holds across the bb.js and - * ipc-runtime package boundaries alike. An error without it is the verification's own verdict. - */ -function isRetryableFailure(err: unknown): boolean { - return err instanceof Error && (err as Error & { retry?: unknown }).retry === true; -} - /** Thrown when no live bb process could check a proof, so the proof was neither accepted nor rejected. */ export class ProofVerifierUnavailableError extends Error { constructor(message: string, options?: ErrorOptions) { From b8657fd2cedbf4142492a2df5d57899981be1431 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:22:46 +0000 Subject: [PATCH 14/19] fix(bb-prover): a pool that fails to start must not wedge the factory Reverting the pool to main's brought back main's permanent wedge, which #351 had also been fixing: initPromise caches its rejection, so one failed spawn made every later borrow fail for the life of the process. It is cleared on failure now, as the bb.js singleton's is, so the next borrow starts the pool again. A borrow also raced destruction, so a pool whose bb never comes up no longer holds up shutdown: the wait ends when the factory is destroyed and the borrow says so. The verifier spends its attempt budget on both halves. A bb that could not be started is worth another go, on the same terms a bb that died under the call gets; anything else, such as the factory being destroyed, will not improve by asking again. Either way no proof was checked, so neither is ever reported as the proof's fault. The double now fails a spawn retryably, as BBJsInstance.create does, rather than showing a test a permanent failure where production has a transient one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- .../bb-prover/src/bb/bb_js_backend.ts | 19 ++++++++++++-- yarn-project/bb-prover/src/test/fake_bb_js.ts | 4 ++- .../src/verifier/bb_verifier.test.ts | 2 +- .../bb-prover/src/verifier/bb_verifier.ts | 26 ++++++++++++------- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 01c62ec910f..8129e566f8f 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -1,6 +1,7 @@ import { type AvmStat, type BackendOptions, BackendType, Barretenberg } from '@aztec-foundation/bb.js'; import type { LogFn, Logger } from '@aztec-labs/foundation/log'; +import { promiseWithResolvers } from '@aztec-labs/foundation/promise'; import { FifoMemoryQueue } from '@aztec-labs/foundation/queue'; import { Timer } from '@aztec-labs/foundation/timer'; import { ProvingError } from '@aztec-labs/stdlib/errors'; @@ -299,6 +300,8 @@ export class BBJsFactory { /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ private initPromise?: Promise; private destroyed = false; + /** Resolved by destroy(), so a borrow waiting on a pool that never starts does not wait forever. */ + private readonly destroyedSignal = promiseWithResolvers(); constructor( private bbPath: string, @@ -328,9 +331,20 @@ export class BBJsFactory { return this.makeOwned(instance); } if (!this.initPromise) { - this.initPromise = this.initPool(); + // A pool that fails to start failed for environmental reasons — a loaded machine, a bb that + // could not spawn — so the failure is not cached: the next borrow tries again rather than the + // factory being wedged for the life of the process. + this.initPromise = this.initPool().catch(err => { + this.initPromise = undefined; + throw err; + }); + } + // Racing destruction as well, so a borrow does not outlive the factory when the pool is still + // starting; a bb that never comes up would otherwise block shutdown indefinitely. + await Promise.race([this.initPromise, this.destroyedSignal.promise]); + if (this.destroyed) { + throw new Error('BBJsFactory has been destroyed'); } - await this.initPromise; const pool = this.pool; if (!pool) { throw new Error('BBJsFactory has been destroyed'); @@ -348,6 +362,7 @@ export class BBJsFactory { * in-flight pooled borrow are destroyed by their dispose callback when released. */ async destroy(): Promise { + this.destroyedSignal.resolve(); if (this.destroyed) { return; } diff --git a/yarn-project/bb-prover/src/test/fake_bb_js.ts b/yarn-project/bb-prover/src/test/fake_bb_js.ts index 280ec61c132..ef675aa8349 100644 --- a/yarn-project/bb-prover/src/test/fake_bb_js.ts +++ b/yarn-project/bb-prover/src/test/fake_bb_js.ts @@ -114,7 +114,9 @@ export class FakeBBJsFactory extends BBJsFactory { await spawned; } if (next instanceof Error) { - throw next; + // BBJsInstance.create wraps a failed spawn as retryable; the double must too, or a test + // sees a permanent failure where production sees a transient one. + throw Object.assign(next, { retry: true }); } const instance = new FakeBBJsInstance(next); this.created.push(instance); diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts index eb95c40825a..2d4f5a0dc5f 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -69,7 +69,7 @@ describe('BBCircuitVerifier', () => { await expect(verifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); }); - it('waits for a bb instance to start rather than rejecting the proof', async () => { + it('retries a failed spawn rather than rejecting the proof', async () => { factory.planNextInstance(new Error('spawn failed')); await expect(verifier.verifyProof(tx)).resolves.toMatchObject({ valid: true }); }); diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index 4c9c229b681..8820b5528fd 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -156,10 +156,26 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { txHash: string, ): Promise<{ verified: boolean; durationMs: number }> { for (let attempt = 1; ; attempt++) { - await using instance = await this.borrowInstance(); + let borrowed: BBJsApi & AsyncDisposable; + try { + borrowed = await this.bbJsFactory.getInstance(); + } catch (err) { + // A bb that could not be started is worth another go, within the same budget a death gets. + // Anything else — the factory destroyed under us — will not improve by asking again. Either + // way no proof was checked, so this is never the proof's fault. + if (isRetryableFailure(err) && attempt < BBCircuitVerifier.MAX_CHONK_VERIFY_ATTEMPTS) { + this.logger.warn('no bb instance available to verify a proof; retrying', { txHash, attempt }); + continue; + } + throw new ProofVerifierUnavailableError('No bb instance available to verify the proof', { cause: err }); + } + + await using instance = borrowed; try { return await instance.verifyChonkProof(fieldsWithPublicInputs, verificationKey); } catch (err) { + // Only an environmental failure is worth retrying; anything else is the verification's own + // verdict and belongs to the caller. if (!isRetryableFailure(err)) { throw err; } @@ -172,14 +188,6 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { } } } - - private async borrowInstance(): Promise { - try { - return await this.bbJsFactory.getInstance(); - } catch (err) { - throw new ProofVerifierUnavailableError('No bb instance available to verify the proof', { cause: err }); - } - } } /** Split a buffer into 32-byte Uint8Array field elements. */ From a1c1f77d0ed378a392be20b074993d550f244ce2 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:17:40 +0000 Subject: [PATCH 15/19] test(bb-prover): fail every spawn attempt in the per-call unavailable test A failed spawn is now retried, so failing it once let the second attempt succeed and the proof verify. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- yarn-project/bb-prover/src/verifier/bb_verifier.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts index 2d4f5a0dc5f..2bb51cc7e26 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -76,6 +76,8 @@ describe('BBCircuitVerifier', () => { it('reports the verifier unavailable when a per-call bb instance cannot be started', async () => { const perCallFactory = new FakeBBJsFactory(); + // A failed spawn is retried, so the instance must fail to start on every attempt. + perCallFactory.planNextInstance(new Error('spawn failed')); perCallFactory.planNextInstance(new Error('spawn failed')); const perCallVerifier = new TestBBCircuitVerifier(perCallFactory); await expect(perCallVerifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); From 7a1f72f8267e284e991c0ad7e24601a8c2006dc2 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:27:51 +0000 Subject: [PATCH 16/19] refactor(bb-prover): share the retry check, and let the verifier opt into respawn The bb-prover and AVM pool copies of the retry check become one type guard in foundation, without a cast. Respawn is now an explicit BBJsFactory option the verifier sets, rather than implied by pooling, so a future pooled owner of stateful sessions does not get it by accident. The retry wording says attempts, since with respawn a retry usually lands on the same instance's new process. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- .../bb-prover/src/bb/bb_js_backend.ts | 26 +++++++------------ .../bb-prover/src/prover/server/bb_prover.ts | 7 ++--- .../bb-prover/src/verifier/bb_verifier.ts | 18 ++++++++----- yarn-project/foundation/src/error/index.ts | 12 +++++++++ .../src/public/avm_simulator_pool.ts | 4 +-- 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 8129e566f8f..e942c5a134c 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -70,18 +70,6 @@ export interface BBJsApi { destroy(): Promise; } -/** - * Whether a failure was environmental, and so may be retried: the bb process died, its connection - * broke, or it could not be started. - * - * The bare `retry` property is the contract, feature-detected rather than imported, so the same - * check holds for errors from bb.js, from ipc-runtime and from ProvingError alike. An error - * without it failed for a reason retrying cannot fix. - */ -export function isRetryableFailure(err: unknown): boolean { - return err instanceof Error && (err as Error & { retry?: unknown }).retry === true; -} - /** * Thin wrapper around a single Barretenberg instance. * Each instance spawns its own bb process via the NativeUnixSocket backend. @@ -271,6 +259,12 @@ export interface BBJsFactoryOptions { * If omitted, every `getInstance()` call spawns a fresh bb that is destroyed on dispose. */ poolSize?: number; + /** + * Let each instance replace its bb process when it dies. Only for callers whose calls stand alone: + * a replacement process remembers nothing, so state held across calls (a Chonk accumulation, a + * batch-verifier session) would be silently lost. + */ + respawn?: boolean; logger?: Logger; threads?: number; debugDir?: string; @@ -300,6 +294,7 @@ export class BBJsFactory { /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ private initPromise?: Promise; private destroyed = false; + private readonly respawn: boolean; /** Resolved by destroy(), so a borrow waiting on a pool that never starts does not wait forever. */ private readonly destroyedSignal = promiseWithResolvers(); @@ -308,6 +303,7 @@ export class BBJsFactory { options: BBJsFactoryOptions = {}, ) { this.poolSize = options.poolSize; + this.respawn = options.respawn ?? false; this.logger = options.logger; this.threads = options.threads; this.debugDir = options.debugDir; @@ -418,11 +414,7 @@ export class BBJsFactory { protected async createInstance(): Promise { const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; - // A pooled instance outlives the call that borrowed it, so it replaces a bb process that dies - // under it and the next borrower gets a working one. Each verification stands alone, so a - // replacement has nothing to carry over. A fresh-per-call instance has nothing to heal. - const respawn = this.poolSize !== undefined; - const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads, respawn); + const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads, this.respawn); return this.maybeWrapDebug(raw); } diff --git a/yarn-project/bb-prover/src/prover/server/bb_prover.ts b/yarn-project/bb-prover/src/prover/server/bb_prover.ts index 5eee2ecbf15..6513bf3ce7e 100644 --- a/yarn-project/bb-prover/src/prover/server/bb_prover.ts +++ b/yarn-project/bb-prover/src/prover/server/bb_prover.ts @@ -9,6 +9,7 @@ import { ULTRA_KECCAK_PROOF_LENGTH, } from '@aztec-labs/constants'; import { Fr } from '@aztec-labs/foundation/curves/bn254'; +import { isRetryableError } from '@aztec-labs/foundation/error'; import { runInDirectory } from '@aztec-labs/foundation/fs'; import { createLogger } from '@aztec-labs/foundation/log'; import { @@ -75,7 +76,7 @@ import { promises as fs } from 'fs'; import { ungzip } from 'pako'; import * as path from 'path'; -import { BBJsFactory, type BBJsProofResult, isRetryableFailure } from '../../bb/bb_js_backend.js'; +import { BBJsFactory, type BBJsProofResult } from '../../bb/bb_js_backend.js'; import type { ACVMConfig, BBConfig } from '../../config.js'; import { getUltraHonkFlavorForCircuit } from '../../honk.js'; import { ProverInstrumentation } from '../../instrumentation.js'; @@ -449,7 +450,7 @@ export class BBNativeRollupProver implements ServerCircuitProver { ); } catch (error) { // Preserve retryability of the underlying failure (e.g. a transient bb startup error). - const retry = isRetryableFailure(error); + const retry = isRetryableError(error); throw new ProvingError(`Failed to generate proof for ${circuitType}: ${error}`, error, retry); } @@ -590,7 +591,7 @@ export class BBNativeRollupProver implements ServerCircuitProver { )); } catch (error) { // Preserve retryability of the underlying failure (e.g. a transient bb startup error). - const retry = isRetryableFailure(error); + const retry = isRetryableError(error); throw new ProvingError(`Failed to verify proof for ${circuitType}: ${error}`, error, retry); } diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index 8820b5528fd..a3cdebd3695 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -1,3 +1,4 @@ +import { isRetryableError } from '@aztec-labs/foundation/error'; import { type Logger, createLogger } from '@aztec-labs/foundation/log'; import { Timer } from '@aztec-labs/foundation/timer'; import { ProtocolCircuitVks } from '@aztec-labs/noir-protocol-circuits-types/server/vks'; @@ -14,7 +15,7 @@ import { Tx } from '@aztec-labs/stdlib/tx'; import type { VerificationKeyData } from '@aztec-labs/stdlib/vks'; import { promises as fs } from 'fs'; -import { type BBJsApi, BBJsFactory, isRetryableFailure } from '../bb/bb_js_backend.js'; +import { type BBJsApi, BBJsFactory } from '../bb/bb_js_backend.js'; import type { BBConfig } from '../config.js'; import { getUltraHonkFlavorForCircuit } from '../honk.js'; @@ -39,10 +40,15 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { ) { // BB_NUM_IVC_VERIFIERS bounds the number of long-lived bb processes the pool keeps alive. // If 0, fall back to spawning a fresh bb per verification. + const poolSize = config.numConcurrentIVCVerifiers > 0 ? config.numConcurrentIVCVerifiers : undefined; this.bbJsFactory = bbJsFactory ?? new BBJsFactory(config.bbBinaryPath, { - poolSize: config.numConcurrentIVCVerifiers > 0 ? config.numConcurrentIVCVerifiers : undefined, + poolSize, + // A pooled instance outlives the call that borrowed it, so it replaces a bb process that dies + // under it and the next borrower gets a working one. Each verification stands alone, so a + // replacement has nothing to carry over. A fresh-per-call instance has nothing to heal. + respawn: poolSize !== undefined, logger, debugDir: config.bbDebugOutputDir, }); @@ -163,7 +169,7 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { // A bb that could not be started is worth another go, within the same budget a death gets. // Anything else — the factory destroyed under us — will not improve by asking again. Either // way no proof was checked, so this is never the proof's fault. - if (isRetryableFailure(err) && attempt < BBCircuitVerifier.MAX_CHONK_VERIFY_ATTEMPTS) { + if (isRetryableError(err) && attempt < BBCircuitVerifier.MAX_CHONK_VERIFY_ATTEMPTS) { this.logger.warn('no bb instance available to verify a proof; retrying', { txHash, attempt }); continue; } @@ -176,15 +182,15 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { } catch (err) { // Only an environmental failure is worth retrying; anything else is the verification's own // verdict and belongs to the caller. - if (!isRetryableFailure(err)) { + if (!isRetryableError(err)) { throw err; } if (attempt >= BBCircuitVerifier.MAX_CHONK_VERIFY_ATTEMPTS) { - throw new ProofVerifierUnavailableError(`bb died while verifying the proof, on ${attempt} instances`, { + throw new ProofVerifierUnavailableError(`bb died while verifying the proof, on ${attempt} attempts`, { cause: err, }); } - this.logger.warn('bb died while verifying a proof; retrying on another instance', { txHash, attempt }); + this.logger.warn('bb died while verifying a proof; retrying', { txHash, attempt }); } } } diff --git a/yarn-project/foundation/src/error/index.ts b/yarn-project/foundation/src/error/index.ts index 36d0783d188..1b3b597a0d5 100644 --- a/yarn-project/foundation/src/error/index.ts +++ b/yarn-project/foundation/src/error/index.ts @@ -20,3 +20,15 @@ export class TimeoutError extends Error { export class AbortError extends Error { public override readonly name = 'AbortError'; } + +/** + * Whether a failure was environmental, and so may be retried: the process behind a call died, its + * connection broke, or it could not be started. + * + * The bare `retry` property is the contract, feature-detected rather than imported, so the same check + * holds across bb.js, ipc-runtime and ProvingError. An error without it failed for a reason retrying + * cannot fix. + */ +export function isRetryableError(err: unknown): boolean { + return err instanceof Error && 'retry' in err && err.retry === true; +} diff --git a/yarn-project/simulator/src/public/avm_simulator_pool.ts b/yarn-project/simulator/src/public/avm_simulator_pool.ts index 9fffa9ea168..91421df242a 100644 --- a/yarn-project/simulator/src/public/avm_simulator_pool.ts +++ b/yarn-project/simulator/src/public/avm_simulator_pool.ts @@ -1,6 +1,6 @@ import { AvmService } from '@aztec-foundation/bb-avm-sim'; -import { AbortError } from '@aztec-labs/foundation/error'; +import { AbortError, isRetryableError } from '@aztec-labs/foundation/error'; import { type Logger, createLogger } from '@aztec-labs/foundation/log'; import { sleep } from '@aztec-labs/foundation/sleep'; @@ -53,7 +53,7 @@ export interface AvmProcessHandle { * lifecycle is invisible to the pool's callers. */ function isProcessFailure(err: unknown): boolean { - return err instanceof Error && (err as Error & { retry?: unknown }).retry === true; + return isRetryableError(err); } /** Sleep that wakes early (without throwing) when the signal aborts; callers re-check the signal. */ From 1962f6058bac6bd4bff9ad89e9c4fdaac803eeb9 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:36:36 +0000 Subject: [PATCH 17/19] fix(bb-prover): race pool startup against destroy only while the pool is starting Every borrow raced the init promise against destroyedSignal, which stays pending until destroy(). Each race leaves a handler on it, so every verification leaked one for the life of the node: about 312 bytes, or ~300 MB per million verifications. Once the pool exists, destroy() already releases waiting borrowers through pool.cancel(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- .../bb-prover/src/bb/bb_js_backend.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index e942c5a134c..1915e29f6aa 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -326,18 +326,23 @@ export class BBJsFactory { const instance = await this.createInstance(); return this.makeOwned(instance); } - if (!this.initPromise) { - // A pool that fails to start failed for environmental reasons — a loaded machine, a bb that - // could not spawn — so the failure is not cached: the next borrow tries again rather than the - // factory being wedged for the life of the process. - this.initPromise = this.initPool().catch(err => { - this.initPromise = undefined; - throw err; - }); + if (!this.pool) { + if (!this.initPromise) { + // A pool that fails to start failed for environmental reasons — a loaded machine, a bb that + // could not spawn — so the failure is not cached: the next borrow tries again rather than the + // factory being wedged for the life of the process. + this.initPromise = this.initPool().catch(err => { + this.initPromise = undefined; + throw err; + }); + } + // Racing destruction as well, so a borrow does not outlive the factory when the pool is still + // starting; a bb that never comes up would otherwise block shutdown indefinitely. Only while it + // is starting: each race leaves a handler on the pending destroyedSignal, so racing on every + // borrow would leak one per verification for the life of the node. Once the pool exists, + // destroy() releases waiting borrowers through pool.cancel() instead. + await Promise.race([this.initPromise, this.destroyedSignal.promise]); } - // Racing destruction as well, so a borrow does not outlive the factory when the pool is still - // starting; a bb that never comes up would otherwise block shutdown indefinitely. - await Promise.race([this.initPromise, this.destroyedSignal.promise]); if (this.destroyed) { throw new Error('BBJsFactory has been destroyed'); } From faad42f1b4d45bed4173bd90e473ddc8b96d9cf5 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:05 +0000 Subject: [PATCH 18/19] refactor(bb-prover): make the bb.js pool a queue of slots that start their bb on demand The pool had a separate startup phase that spawned every instance at once, and each failure mode it introduced needed its own patch: a cached startup failure that wedged the factory, a shutdown that hung while it ran, and a race against destroy that leaked a handler per borrow. Without that phase there is nothing to patch. The pool is a queue of slots, created up front; a borrower takes one and starts its bb if it has none. A borrower now waits only for a slot, which destroy() releases by cancelling the queue, or for its own bb to start, which bb.js bounds with its startup deadline. A bb that fails to start costs only its slot, which goes back empty, so the instances that did start are kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- .../bb-prover/src/bb/bb_js_backend.test.ts | 54 +++++++ .../bb-prover/src/bb/bb_js_backend.ts | 138 ++++++------------ .../src/verifier/bb_verifier.test.ts | 29 +++- 3 files changed, 123 insertions(+), 98 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index c60fc88b29c..0fc5becc206 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -1,5 +1,6 @@ import { ProvingError } from '@aztec-labs/stdlib/errors'; +import { FakeBBJsFactory } from '../test/fake_bb_js.js'; import { BBJsInstance } from './bb_js_backend.js'; describe('BBJsInstance', () => { @@ -9,3 +10,56 @@ describe('BBJsInstance', () => { expect(err.retry).toBe(true); }); }); + +describe('BBJsFactory pool', () => { + const tick = () => new Promise(resolve => setImmediate(resolve)); + + it('reuses its instances, starting no more than the pool holds', async () => { + const factory = new FakeBBJsFactory(1); + const borrow = async () => { + await using _instance = await factory.getInstance(); + await tick(); + }; + await Promise.all([borrow(), borrow(), borrow()]); + expect(factory.created).toHaveLength(1); + + await factory.destroy(); + expect(factory.created[0].destroyCount).toBe(1); + }); + + it('gives a slot whose bb failed to start to the next borrower, which starts it again', async () => { + const factory = new FakeBBJsFactory(1); + factory.planNextInstance(new Error('spawn failed')); + await expect(factory.getInstance()).rejects.toThrow('spawn failed'); + + await using _instance = await factory.getInstance(); + expect(factory.created).toHaveLength(1); + }); + + it('releases a borrower waiting for a slot at once on destroy, and one starting bb when its start ends', async () => { + const factory = new FakeBBJsFactory(1); + let finishStart!: () => void; + factory.planNextInstance([], new Promise(resolve => (finishStart = resolve))); + const starting = factory.getInstance(); + const waiting = factory.getInstance(); + await tick(); + + const destroying = factory.destroy(); + await expect(waiting).rejects.toThrow(/destroyed/); + finishStart(); + await expect(starting).rejects.toThrow(/destroyed/); + await destroying; + expect(factory.created[0].destroyCount).toBe(1); + }); + + it('destroys an instance borrowed across destroy when it is released, rather than pooling it', async () => { + const factory = new FakeBBJsFactory(1); + const instance = await factory.getInstance(); + await factory.destroy(); + expect(factory.created[0].destroyCount).toBe(0); + + await instance[Symbol.asyncDispose](); + expect(factory.created[0].destroyCount).toBe(1); + await expect(factory.getInstance()).rejects.toThrow(/destroyed/); + }); +}); diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 1915e29f6aa..c04d980eea7 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -1,7 +1,6 @@ import { type AvmStat, type BackendOptions, BackendType, Barretenberg } from '@aztec-foundation/bb.js'; import type { LogFn, Logger } from '@aztec-labs/foundation/log'; -import { promiseWithResolvers } from '@aztec-labs/foundation/promise'; import { FifoMemoryQueue } from '@aztec-labs/foundation/queue'; import { Timer } from '@aztec-labs/foundation/timer'; import { ProvingError } from '@aztec-labs/stdlib/errors'; @@ -270,12 +269,20 @@ export interface BBJsFactoryOptions { debugDir?: string; } +/** A place in a {@link BBJsFactory} pool: empty until a borrower first starts a bb in it. */ +type PoolSlot = { instance?: BBJsApi }; + /** * Manages bb.js instance lifecycle. By default every `getInstance()` call spawns a fresh * bb process that is destroyed when the borrow is disposed. Pass `poolSize` to keep a fixed * set of long-lived bb processes that are reused across calls — useful when the per-call * bb startup cost dominates the workload (e.g. high-rate IVC verification). * + * The pool is a queue of `poolSize` slots, each starting its bb on the first borrow that needs it. + * A borrower waits in exactly two places: for a slot, which destroy() releases by cancelling the + * queue, and for its slot's bb to start, which bb.js bounds with its own startup deadline. A bb that + * fails to start costs only that slot, which goes back empty for the next borrower to try again. + * * Idiomatic usage: * ``` * await using inst = await factory.getInstance(); @@ -284,103 +291,86 @@ export interface BBJsFactoryOptions { * ``` */ export class BBJsFactory { - private readonly poolSize?: number; private readonly logger?: Logger; private readonly threads?: number; private readonly debugDir?: string; + private readonly respawn: boolean; - /** Available pooled instances when poolSize is set; otherwise undefined. */ - private pool?: FifoMemoryQueue; - /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ - private initPromise?: Promise; + /** Slots not currently borrowed, when poolSize is set; otherwise undefined. */ + private readonly slots?: FifoMemoryQueue; private destroyed = false; - private readonly respawn: boolean; - /** Resolved by destroy(), so a borrow waiting on a pool that never starts does not wait forever. */ - private readonly destroyedSignal = promiseWithResolvers(); constructor( private bbPath: string, options: BBJsFactoryOptions = {}, ) { - this.poolSize = options.poolSize; this.respawn = options.respawn ?? false; this.logger = options.logger; this.threads = options.threads; this.debugDir = options.debugDir; - if (this.poolSize !== undefined && this.poolSize < 1) { - throw new Error(`BBJsFactory poolSize must be >= 1, got ${this.poolSize}`); + if (options.poolSize !== undefined) { + if (options.poolSize < 1) { + throw new Error(`BBJsFactory poolSize must be >= 1, got ${options.poolSize}`); + } + this.slots = new FifoMemoryQueue(); + for (let i = 0; i < options.poolSize; i++) { + this.slots.put({}); + } } } /** * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. - * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows from - * the pool and returns to it on dispose. + * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows a slot, + * starting its bb if it has none, and returns the slot to the pool on dispose. */ async getInstance(): Promise { if (this.destroyed) { throw new Error('BBJsFactory has been destroyed'); } - if (this.poolSize === undefined) { + if (!this.slots) { // No pool: fresh-per-call, dispose destroys. const instance = await this.createInstance(); return this.makeOwned(instance); } - if (!this.pool) { - if (!this.initPromise) { - // A pool that fails to start failed for environmental reasons — a loaded machine, a bb that - // could not spawn — so the failure is not cached: the next borrow tries again rather than the - // factory being wedged for the life of the process. - this.initPromise = this.initPool().catch(err => { - this.initPromise = undefined; - throw err; - }); - } - // Racing destruction as well, so a borrow does not outlive the factory when the pool is still - // starting; a bb that never comes up would otherwise block shutdown indefinitely. Only while it - // is starting: each race leaves a handler on the pending destroyedSignal, so racing on every - // borrow would leak one per verification for the life of the node. Once the pool exists, - // destroy() releases waiting borrowers through pool.cancel() instead. - await Promise.race([this.initPromise, this.destroyedSignal.promise]); + const slot = await this.slots.get(); + if (!slot) { + throw new Error('BBJsFactory was destroyed while waiting for an instance'); } - if (this.destroyed) { - throw new Error('BBJsFactory has been destroyed'); + let instance: BBJsApi; + try { + instance = slot.instance ??= await this.createInstance(); + } catch (err) { + await this.release(slot); + throw err; } - const pool = this.pool; - if (!pool) { + if (this.destroyed) { + await this.release(slot); throw new Error('BBJsFactory has been destroyed'); } - const instance = await pool.get(); - if (!instance) { - throw new Error('BBJsFactory was destroyed while waiting for an instance'); - } - return this.makeBorrowed(instance); + return this.makeDisposable(instance, () => this.release(slot)); } /** * Tear down all pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call - * instances are destroyed by their own dispose callbacks). Instances currently held by an - * in-flight pooled borrow are destroyed by their dispose callback when released. + * instances are destroyed by their own dispose callbacks). Instances in borrowed slots are + * destroyed when their borrow is released. */ async destroy(): Promise { - this.destroyedSignal.resolve(); if (this.destroyed) { return; } this.destroyed = true; - const pool = this.pool; - this.pool = undefined; - if (!pool) { + if (!this.slots) { return; } const idle: BBJsApi[] = []; - while (pool.length() > 0) { - const item = pool.getImmediate(); - if (item) { - idle.push(item); + for (let slot = this.slots.getImmediate(); slot; slot = this.slots.getImmediate()) { + if (slot.instance) { + idle.push(slot.instance); } } - pool.cancel(); + this.slots.cancel(); // Aggregate teardown failures so a single bb child that fails to shut down doesn't mask others. const results = await Promise.allSettled(idle.map(item => item.destroy())); const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); @@ -389,32 +379,13 @@ export class BBJsFactory { } } - private async initPool(): Promise { - // Use allSettled so that if any createInstance() rejects we can destroy the rest instead of - // leaking bb child processes whose creation succeeded. - const results = await Promise.allSettled(Array.from({ length: this.poolSize! }, () => this.createInstance())); - const items: BBJsApi[] = []; - const errors: unknown[] = []; - for (const result of results) { - if (result.status === 'fulfilled') { - items.push(result.value); - } else { - errors.push(result.reason); - } - } - if (errors.length > 0 || this.destroyed) { - // Either creation failed or destroy() raced ahead — clean up everything we successfully spawned. - await Promise.all(items.map(item => item.destroy())); - if (errors.length > 0) { - throw errors[0]; - } - return; - } - const pool = new FifoMemoryQueue(); - for (const item of items) { - pool.put(item); + /** Return a borrowed slot to the pool, or destroy its bb if the factory was destroyed meanwhile. */ + private async release(slot: PoolSlot): Promise { + if (!this.destroyed) { + this.slots!.put(slot); + } else { + await slot.instance?.destroy(); } - this.pool = pool; } protected async createInstance(): Promise { @@ -441,21 +412,6 @@ export class BBJsFactory { return this.makeDisposable(instance, () => instance.destroy()); } - /** - * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool (or destroys it - * if the factory was destroyed in the meantime). Destroy errors are propagated. - */ - private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { - return this.makeDisposable(instance, async () => { - const pool = this.pool; - if (pool && !this.destroyed) { - pool.put(instance); - } else { - await instance.destroy(); - } - }); - } - private makeDisposable(instance: BBJsApi, onDispose: () => void | Promise): BBJsApi & AsyncDisposable { let disposed = false; const dispose = async (): Promise => { diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts index 2bb51cc7e26..78d2c2461f1 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -83,14 +83,29 @@ describe('BBCircuitVerifier', () => { await expect(perCallVerifier.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); }); - it('stops a queued verifier while a verification waits for a bb instance that never starts', async () => { - factory.planNextInstance([], new Promise(() => {})); - const queued = new QueuedIVCVerifier(verifier, 1); - const verificationFails = expect(queued.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); - // Let the verification start waiting for the pool before stopping. + it('starts no more bb processes than the pool holds, however many verifications arrive', async () => { + const results = await Promise.all([1, 2, 3].map(() => verifier.verifyProof(tx))); + expect(results.every(r => r.valid)).toBe(true); + expect(factory.created).toHaveLength(1); + }); + + it('stops a queued verifier while verifications wait on a bb instance that is still starting', async () => { + // bb.js bounds how long a start can take; this one finishes only when the test lets it. + let finishStart!: () => void; + factory.planNextInstance([], new Promise(resolve => (finishStart = resolve))); + const queued = new QueuedIVCVerifier(verifier, 2); + const starting = expect(queued.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + const waiting = expect(queued.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); + // Let one verification take the pool's only slot and the other queue for it. await new Promise(resolve => setImmediate(resolve)); - await queued.stop(); - await verificationFails; + const stopping = queued.stop(); + // The verification waiting for a slot is released at once; the one starting bb, when the start ends. + await waiting; + finishStart(); + await starting; + await stopping; + // The bb that finished starting after the stop was not left running. + expect(factory.created[0].destroyCount).toBe(1); }); }); From 5948da5960db89fc1bf4be2228371335cca6a7e9 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:18:53 +0000 Subject: [PATCH 19/19] test(bb-prover): gate the start in the pool shutdown tests with promiseWithResolvers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- yarn-project/bb-prover/src/bb/bb_js_backend.test.ts | 7 ++++--- yarn-project/bb-prover/src/verifier/bb_verifier.test.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts index 0fc5becc206..e26fbd89a2c 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.test.ts @@ -1,3 +1,4 @@ +import { promiseWithResolvers } from '@aztec-labs/foundation/promise'; import { ProvingError } from '@aztec-labs/stdlib/errors'; import { FakeBBJsFactory } from '../test/fake_bb_js.js'; @@ -38,15 +39,15 @@ describe('BBJsFactory pool', () => { it('releases a borrower waiting for a slot at once on destroy, and one starting bb when its start ends', async () => { const factory = new FakeBBJsFactory(1); - let finishStart!: () => void; - factory.planNextInstance([], new Promise(resolve => (finishStart = resolve))); + const start = promiseWithResolvers(); + factory.planNextInstance([], start.promise); const starting = factory.getInstance(); const waiting = factory.getInstance(); await tick(); const destroying = factory.destroy(); await expect(waiting).rejects.toThrow(/destroyed/); - finishStart(); + start.resolve(); await expect(starting).rejects.toThrow(/destroyed/); await destroying; expect(factory.created[0].destroyCount).toBe(1); diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts index 78d2c2461f1..d128c88dfed 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.test.ts @@ -1,4 +1,5 @@ import { createLogger } from '@aztec-labs/foundation/log'; +import { promiseWithResolvers } from '@aztec-labs/foundation/promise'; import { mockTx } from '@aztec-labs/stdlib/testing'; import type { Tx } from '@aztec-labs/stdlib/tx'; @@ -91,8 +92,8 @@ describe('BBCircuitVerifier', () => { it('stops a queued verifier while verifications wait on a bb instance that is still starting', async () => { // bb.js bounds how long a start can take; this one finishes only when the test lets it. - let finishStart!: () => void; - factory.planNextInstance([], new Promise(resolve => (finishStart = resolve))); + const start = promiseWithResolvers(); + factory.planNextInstance([], start.promise); const queued = new QueuedIVCVerifier(verifier, 2); const starting = expect(queued.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); const waiting = expect(queued.verifyProof(tx)).rejects.toBeInstanceOf(ProofVerifierUnavailableError); @@ -102,7 +103,7 @@ describe('BBCircuitVerifier', () => { const stopping = queued.stop(); // The verification waiting for a slot is released at once; the one starting bb, when the start ends. await waiting; - finishStart(); + start.resolve(); await starting; await stopping; // The bb that finished starting after the stop was not left running.