-
Notifications
You must be signed in to change notification settings - Fork 590
Description
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