Skip to content

Commit 5e239c7

Browse files
authored
fix(simulator): make circuit recorder concurrency-safe via AsyncLocalStorage (#24112)
Holds the active circuit recording in `AsyncLocalStorage` instead of shared instance fields, so nested and concurrent executions no longer corrupt each other's recording. **Problem:** `CircuitRecorder` kept mutable state (`recording`/`stackDepth`/`newCircuit`) on one per-PXE instance. Nested re-entrant executions (nested private calls via `aztec_prv_callPrivateFunction`, utility executions) shared that state, so a child could reset the parent's recording mid-flight and drop or misattribute oracle calls. The error path then dereferenced the absent recording and masked the real failure (e.g. `schnorr_initializerless: capsule load failed`). The first two commits guarded the crash; this fixes the cause and leaves the guards as redundant defense. **Fix:** - Per-execution recording lives in `AsyncLocalStorage` (mirrors `foundation/src/profiler/profiler.ts`) - `record(metadata, fn)` replaces `start`/`finish`/`finishWithError` and re-throws the original error unchanged. - No `CircuitSimulator` interface or consumer change. **Tests:** oracle-call attribution across nested private + utility re-entries (red before this change: parent `oracles` came back `undefined`), isolation of interleaved concurrent recordings, and original simulator error surfacing without masking.
1 parent d074c17 commit 5e239c7

6 files changed

Lines changed: 278 additions & 144 deletions

File tree

yarn-project/pxe/src/entrypoints/server/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'
33
import { createLogger } from '@aztec/foundation/log';
44
import { createStore } from '@aztec/kv-store/lmdb-v2';
55
import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle';
6-
import { MemoryCircuitRecorder, SimulatorRecorderWrapper, WASMSimulator } from '@aztec/simulator/client';
6+
import { WASMSimulator } from '@aztec/simulator/client';
7+
import { MemoryCircuitRecorder, SimulatorRecorderWrapper } from '@aztec/simulator/server';
78
import { FileCircuitRecorder } from '@aztec/simulator/testing';
89
import { getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry';
910
import { getStandardHandshakeRegistry } from '@aztec/standard-contracts/handshake-registry';
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
export * from './private/acvm/index.js';
22
export { WASMSimulator } from './private/acvm_wasm.js';
3-
export { SimulatorRecorderWrapper } from './private/circuit_recording/simulator_recorder_wrapper.js';
4-
export { MemoryCircuitRecorder } from './private/circuit_recording/memory_circuit_recorder.js';
53
export { type CircuitSimulator, type DecodedError } from './private/circuit_simulator.js';
64
export * from './common/index.js';
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { FunctionArtifactWithContractName } from '@aztec/stdlib/abi';
2+
3+
import type { ACIRCallback, ACIRExecutionResult } from '../acvm/acvm.js';
4+
import type { ACVMWitness } from '../acvm/acvm_types.js';
5+
import type { CircuitSimulator } from '../circuit_simulator.js';
6+
import type { RecordingMetadata } from './circuit_recorder.js';
7+
import { FileCircuitRecorder } from './file_circuit_recorder.js';
8+
import { MemoryCircuitRecorder } from './memory_circuit_recorder.js';
9+
import { SimulatorRecorderWrapper } from './simulator_recorder_wrapper.js';
10+
11+
const witness = () => new Map<number, string>() as ACVMWitness;
12+
const meta = (circuitName: string): RecordingMetadata => ({
13+
input: witness(),
14+
bytecode: Buffer.from(circuitName),
15+
circuitName,
16+
functionName: 'main',
17+
});
18+
19+
describe('CircuitRecorder', () => {
20+
describe('recordCall outside an active recording', () => {
21+
// recordCall must never throw when there is no recording in context (e.g. a stray call after the scope closed):
22+
// it returns the entry it would have recorded without pushing it anywhere.
23+
const expectedEntry = { name: 'loadCapsule', inputs: [['0x01']], outputs: ['0x02'], time: 5, stackDepth: 0 };
24+
25+
it('MemoryCircuitRecorder.recordCall() returns the entry without pushing to an absent recording', async () => {
26+
const recorder = new MemoryCircuitRecorder();
27+
28+
await expect(recorder.recordCall('loadCapsule', [['0x01']], ['0x02'], 5)).resolves.toEqual(expectedEntry);
29+
});
30+
31+
it('FileCircuitRecorder.recordCall() returns the entry without touching the recording file', async () => {
32+
const recorder = new FileCircuitRecorder('/tmp/circuit-recorder-test-unused');
33+
34+
await expect(recorder.recordCall('loadCapsule', [['0x01']], ['0x02'], 5)).resolves.toEqual(expectedEntry);
35+
});
36+
});
37+
38+
describe('record', () => {
39+
it('isolates oracle calls between concurrent recordings sharing one recorder', async () => {
40+
const recorder = new MemoryCircuitRecorder();
41+
let releaseA: () => void;
42+
const gateA = new Promise<void>(resolve => (releaseA = resolve));
43+
44+
// A records a1, suspends so B interleaves, then records a2. With a single shared recording this corrupts; with
45+
// a per-recording context each recording sees only its own calls regardless of interleaving.
46+
const a = recorder.record(meta('A'), async () => {
47+
await recorder.recordCall('a1', [], [], 0);
48+
await gateA;
49+
await recorder.recordCall('a2', [], [], 0);
50+
return 'A';
51+
});
52+
const b = recorder.record(meta('B'), async () => {
53+
await recorder.recordCall('b1', [], [], 0);
54+
releaseA();
55+
return 'B';
56+
});
57+
58+
const [resultA, resultB] = await Promise.all([a, b]);
59+
expect(resultA.recording.oracleCalls.map(o => o.name)).toEqual(['a1', 'a2']);
60+
expect(resultB.recording.oracleCalls.map(o => o.name)).toEqual(['b1']);
61+
});
62+
63+
it('attaches the error to the recording and re-throws it unchanged', async () => {
64+
const recorder = new MemoryCircuitRecorder();
65+
const underlyingError = new Error('schnorr_initializerless: capsule load failed');
66+
67+
await expect(recorder.record(meta('X'), () => Promise.reject(underlyingError))).rejects.toBe(underlyingError);
68+
});
69+
});
70+
});
71+
72+
describe('SimulatorRecorderWrapper', () => {
73+
const artifact = (name: string) =>
74+
({ bytecode: Buffer.from(name), contractName: 'TestContract', name }) as FunctionArtifactWithContractName;
75+
76+
it('surfaces the original simulator error instead of masking it', async () => {
77+
const underlyingError = new Error('schnorr_initializerless: capsule load failed');
78+
const simulator: CircuitSimulator = {
79+
executeUserCircuit: () => Promise.reject(underlyingError),
80+
executeProtocolCircuit: () => Promise.reject(new Error('not used in this test')),
81+
};
82+
const wrapper = new SimulatorRecorderWrapper(simulator, new MemoryCircuitRecorder());
83+
84+
await expect(wrapper.executeUserCircuit(witness(), artifact('test_fn'), {})).rejects.toThrow(
85+
'schnorr_initializerless: capsule load failed',
86+
);
87+
});
88+
89+
// A top-level circuit makes a nested private call (via the aztec_prv_callPrivateFunction oracle, which re-enters
90+
// the simulator) followed by a utility execution (a direct re-entry, as utility_execution_oracle.ts does). Each
91+
// re-entry shares the same recorder, so oracle calls must stay attributed to the circuit that made them and the
92+
// parent recording must survive its children.
93+
it('attributes oracle calls to the right circuit across nested private and utility re-entries', async () => {
94+
const recorder = new MemoryCircuitRecorder();
95+
const oracle = (): ReturnType<ACIRCallback[string]> => Promise.resolve([]);
96+
97+
let childResult: ACIRExecutionResult | undefined;
98+
let utilityResult: ACIRExecutionResult | undefined;
99+
100+
// Indirection so the scripted simulator and callbacks can re-enter through the recorder-wrapped simulator, which
101+
// is constructed below (it wraps `simulator`, so the two reference each other).
102+
const reentry: { wrapper?: CircuitSimulator } = {};
103+
104+
const childCallback: ACIRCallback = { childOracle: oracle };
105+
const utilityCallback: ACIRCallback = { utilityOracle: oracle };
106+
const parentCallback: ACIRCallback = { getNotes: oracle, getMore: oracle };
107+
// The wrapped aztec_prv_callPrivateFunction triggers a nested circuit execution, mirroring private_execution.ts.
108+
parentCallback['aztec_prv_callPrivateFunction'] = async () => {
109+
childResult = await reentry.wrapper!.executeUserCircuit(witness(), artifact('child'), childCallback);
110+
return [];
111+
};
112+
113+
const simulator: CircuitSimulator = {
114+
executeProtocolCircuit: () => Promise.reject(new Error('not used in this test')),
115+
executeUserCircuit: async (_input, artifactArg, callback) => {
116+
switch (artifactArg.name) {
117+
case 'parent':
118+
await callback.getNotes();
119+
await callback['aztec_prv_callPrivateFunction']();
120+
// Utility re-entry: a direct nested executeUserCircuit, not via aztec_prv_callPrivateFunction.
121+
utilityResult = await reentry.wrapper!.executeUserCircuit(witness(), artifact('utility'), utilityCallback);
122+
await callback.getMore();
123+
break;
124+
case 'child':
125+
await callback.childOracle();
126+
break;
127+
case 'utility':
128+
await callback.utilityOracle();
129+
break;
130+
}
131+
return { partialWitness: new Map(), returnWitness: new Map() } as ACIRExecutionResult;
132+
},
133+
};
134+
135+
reentry.wrapper = new SimulatorRecorderWrapper(simulator, recorder);
136+
const parentResult = await reentry.wrapper.executeUserCircuit(witness(), artifact('parent'), parentCallback);
137+
138+
// The parent recording survives both children, with only the parent's own oracle calls attributed to it.
139+
expect(parentResult.oracles).toBeDefined();
140+
expect(Object.keys(parentResult.oracles!).sort()).toEqual(['aztec_prv_callPrivateFunction', 'getMore', 'getNotes']);
141+
// Each child circuit only sees its own oracle call, not the parent's.
142+
expect(Object.keys(childResult!.oracles!)).toEqual(['childOracle']);
143+
expect(Object.keys(utilityResult!.oracles!)).toEqual(['utilityOracle']);
144+
});
145+
});

0 commit comments

Comments
 (0)