Skip to content

[BUG] PXE.stop() does not release all resources #20446

@wei3erHase

Description

@wei3erHase

What are you trying to do?

Problem

Calling PXE.stop() (and by extension TestWallet.stop()) only ends the internal job queue. It does not stop the block synchronizer's polling stream or close the LMDB data store, leaving active timers and open file descriptors that prevent the Node.js process from exiting.

This affects any consumer that creates a PXE or TestWallet and expects the process to terminate cleanly after calling stop() — including benchmark tooling, test harnesses, and CLI scripts.

Root Cause

PXE.stop() — only stops the job queue

// @aztec/pxe/src/pxe.ts:1101-1103
public stop(): Promise<void> {
  return this.jobQueue.end();
}

Three resources created during PXE initialization are never cleaned up:

1. L2BlockStream — active RunningPromise with timers

BlockSynchronizer holds an L2BlockStream that continuously polls for new blocks via a RunningPromise. It has a stop() method, but nobody calls it:

// @aztec/pxe/src/block_synchronizer/block_synchronizer.ts:20-23
export class BlockSynchronizer implements L2BlockStreamEventHandler {
  private log: Logger;
  private isSyncing: Promise<void> | undefined;
  protected readonly blockStream: L2BlockStream;
  // ...
}
// @aztec/stdlib/src/block/l2_block_stream/l2_block_stream.ts:51-53
public async stop() {
  await this.runningPromise.stop();
}

The RunningPromise uses InterruptibleSleep internally, which keeps setTimeout handles on the event loop.

2. LMDB store — open file descriptors and worker channel

The LMDB store is passed to BlockSynchronizer during PXE construction. It has a close() method, but nobody calls it:

// @aztec/kv-store/src/lmdb-v2/store.ts:171-179
async close() {
  if (!this.open) {
    return;
  }
  this.open = false;
  await this.writerQueue.cancel();
  await this.channel.sendMessage(LMDBMessageType.CLOSE, undefined);
}

The open MsgpackChannel and memory-mapped files keep the process alive.

3. HTTP keep-alive sockets from createAztecNodeClient

The JSON-RPC client (created via createAztecNodeClient) uses Node.js built-in fetch (undici), which maintains keep-alive TCP connections to the Aztec node. These Socket handles persist after stop() with no exposed cleanup method on the node client:

// @aztec/foundation/src/json-rpc/client/fetch.ts:35
resp = await fetch(host, {
  method: 'POST',
  body: jsonStringify(body),
  headers: { 'content-type': 'application/json', ...extraHeaders },
});

After stop(), process._getActiveHandles() still shows two Socket instances from these connections.

Proposed Fix

PXE.stop() should stop the block stream and close the store before ending the job queue:

// @aztec/pxe/src/pxe.ts
public async stop(): Promise<void> {
  await this.blockStateSynchronizer.stop();
  await this.jobQueue.end();
}

Where BlockSynchronizer gets a new stop() method:

// @aztec/pxe/src/block_synchronizer/block_synchronizer.ts
public async stop(): Promise<void> {
  await this.blockStream.stop();
  await this.store.close();
}

This keeps the cleanup colocated with ownership — BlockSynchronizer owns the stream and was given the store, so it should be responsible for tearing them down.

Affected Packages

Package Version File
@aztec/pxe 4.0.0-devnet.1-patch.0 src/pxe.ts
@aztec/pxe 4.0.0-devnet.1-patch.0 src/block_synchronizer/block_synchronizer.ts
@aztec/stdlib 4.0.0-devnet.1-patch.0 src/block/l2_block_stream/l2_block_stream.ts
@aztec/kv-store 4.0.0-devnet.1-patch.0 src/lmdb-v2/store.ts
@aztec/test-wallet 4.0.0-devnet.1-patch.0 src/wallet/test_wallet.ts
@aztec/foundation 4.0.0-devnet.1-patch.0 src/json-rpc/client/fetch.ts

Impact

Without this fix, any process using TestWallet or PXE directly must resort to process.exit(0) to terminate, which:

  • Skips any post-teardown logic (saving results, logging, cleanup)
  • Prevents running multiple benchmarks/tests sequentially in a single process
  • Masks potential resource leaks in long-running services

Code Reference

// what we do
const wallet = await TestWallet.create(aztecNode);
// ... use wallet ...
await wallet.stop();
// process hangs here forever

// what we have to do as a workaround
const pxe = (wallet as any).pxe;
await pxe.blockStateSynchronizer.blockStream.stop();  // stops RunningPromise timers
await pxe.blockStateSynchronizer.store.close();        // closes LMDB native channel
await wallet.stop();                                   // ends job queue
// destroy leftover HTTP keep-alive sockets
for (const h of (process as any)._getActiveHandles()) {
  if (h?.constructor?.name === 'Socket' && !h.destroyed) h.destroy();
}

Aztec Version

4.0.0-devnet.1-patch.0 (live since at least v3.0.0)

OS

No response

Browser (if relevant)

No response

Node Version

No response

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    T-bugType: Bug. Something is broken.from-communityThis originated from the community :)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions