1- import { spawn , ChildProcess } from "child_process" ;
1+ import { spawn , ChildProcess , execSync } from "child_process" ;
22import { EventEmitter } from "events" ;
33import { createAztecNodeClient } from "@aztec/aztec.js/node" ;
4+ import net from "node:net" ;
5+ import { readFileSync } from "node:fs" ;
6+ import { fileURLToPath } from "node:url" ;
7+ import { dirname , join } from "node:path" ;
48
59// Global reference for the active sandbox manager
610let activeSandboxManager : SandboxManager | null = null ;
@@ -54,6 +58,8 @@ class SandboxManager extends EventEmitter {
5458 public forceKillTimeout = 5000 ;
5559 public maxRetries = 3 ;
5660 public verbose : boolean ;
61+ public port : number ;
62+ public url : string ;
5763
5864 // Timer/interval tracking for centralized cleanup
5965 private timers : Record < string , NodeJS . Timeout > = { } ;
@@ -65,12 +71,69 @@ class SandboxManager extends EventEmitter {
6571 super ( ) ;
6672 // Enable verbose mode in CI environments by default
6773 this . verbose = options . verbose ?? Boolean ( process . env . CI ) ;
74+ this . port = 8080 ;
75+ this . url = "http://localhost:8080" ;
6876
6977 // Register this manager for signal handling
7078 activeSandboxManager = this ;
7179 setupSignalHandlers ( ) ;
7280 }
7381
82+ /**
83+ * Returns true if a local TCP port is available for binding.
84+ */
85+ private async isPortAvailable ( port : number ) : Promise < boolean > {
86+ try {
87+ await new Promise < void > ( ( resolve , reject ) => {
88+ const server = net . createServer ( ) ;
89+ server . unref ( ) ;
90+ server . on ( "error" , reject ) ;
91+ server . listen ( { port, host : "::" , ipv6Only : false } , ( ) => {
92+ server . close ( ( ) => resolve ( ) ) ;
93+ } ) ;
94+ } ) ;
95+ return true ;
96+ } catch {
97+ return false ;
98+ }
99+ }
100+
101+ private getExpectedAztecVersion ( ) : string {
102+ const __filename = fileURLToPath ( import . meta. url ) ;
103+ const __dirname = dirname ( __filename ) ;
104+ const packageJsonPath = join ( __dirname , ".." , "package.json" ) ;
105+ const packageJson = JSON . parse ( readFileSync ( packageJsonPath , "utf8" ) ) as {
106+ config ?: { aztecVersion ?: string } ;
107+ } ;
108+ const expected = packageJson . config ?. aztecVersion ;
109+ if ( ! expected ) {
110+ throw new Error ( "No aztecVersion found in package.json config" ) ;
111+ }
112+ return expected ;
113+ }
114+
115+ private async tryConnectAndValidateRunningSandbox ( ) : Promise < boolean > {
116+ try {
117+ const aztecNode = await createAztecNodeClient ( this . url , { } ) ;
118+ const nodeInfo = await aztecNode . getNodeInfo ( ) ;
119+ const expected = this . getExpectedAztecVersion ( ) ;
120+ if ( nodeInfo . nodeVersion !== expected ) {
121+ throw new Error (
122+ `Aztec sandbox already running but version mismatch.\n` +
123+ `Expected: ${ expected } \n` +
124+ `Running: ${ nodeInfo . nodeVersion } ` ,
125+ ) ;
126+ }
127+
128+ console . log ( `🔧 Node version: ${ nodeInfo . nodeVersion } ` ) ;
129+ this . isExternalSandbox = true ;
130+ this . isReady = true ;
131+ return true ;
132+ } catch {
133+ return false ;
134+ }
135+ }
136+
74137 /**
75138 * Create a managed timer that will be automatically cleaned up
76139 */
@@ -165,11 +228,23 @@ class SandboxManager extends EventEmitter {
165228 * Spawn the Aztec sandbox process
166229 */
167230 spawnSandboxProcess ( ) : ChildProcess {
168- // In devnet.2, an L1 RPC URL is required
169- // The sandbox will start its own Anvil instance on the default port
170- const l1RpcUrl = process . env . L1_RPC_URL || "http://127.0.0.1:8545" ;
231+ // Prefer `--sandbox` if supported by the installed Aztec CLI; otherwise fall back to `--local-network`.
232+ // This keeps compatibility across Aztec CLI versions.
233+ let modeFlag : "--sandbox" | "--local-network" = "--sandbox" ;
234+ try {
235+ const help = execSync ( "aztec start --help" , {
236+ encoding : "utf8" ,
237+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
238+ } ) ;
239+ if ( ! help . includes ( "--sandbox" ) ) {
240+ modeFlag = "--local-network" ;
241+ }
242+ } catch {
243+ // If help fails for any reason, fall back to local-network since it's supported in current releases.
244+ modeFlag = "--local-network" ;
245+ }
171246
172- return spawn ( "aztec" , [ "start" , "--sandbox" , "--l1-rpc-urls " , l1RpcUrl ] , {
247+ return spawn ( "aztec" , [ "start" , modeFlag , "--port " , String ( this . port ) ] , {
173248 stdio : "pipe" ,
174249 } ) ;
175250 }
@@ -221,29 +296,36 @@ class SandboxManager extends EventEmitter {
221296 console . log ( `🚨 Sandbox error: ${ output } ` ) ;
222297 }
223298
224- // Check for port already in use
225- if ( output . includes ( "port is already" ) ) {
226- this . clearManagedTimer ( "startupTimeout" ) ; // Clear startup timeout since we're switching to external
227- console . log (
228- "ℹ️ Port is already in use, checking if existing sandbox is responsive" ,
229- ) ;
299+ // If the process couldn't bind because something is already running, attach to it and validate version.
300+ if (
301+ output . includes ( "port is already" ) ||
302+ output . includes ( "address already in use" ) ||
303+ output . includes ( "EADDRINUSE" )
304+ ) {
305+ this . clearManagedTimer ( "startupTimeout" ) ;
230306
231307 // Clean up our failed spawn process since we'll use external sandbox
232308 if ( this . process ) {
233309 this . process . kill ( "SIGTERM" ) ;
234310 }
235311 this . process = null ;
236312
237- this . checkSandboxConnectivity ( )
238- . then ( ( ) => {
239- this . isExternalSandbox = true ; // Mark that we're using external sandbox
240- this . isReady = true ;
241- console . log ( "✅ Connected to existing external sandbox" ) ;
242- safeResolve ( this ) ;
313+ this . tryConnectAndValidateRunningSandbox ( )
314+ . then ( ( ok ) => {
315+ if ( ok ) {
316+ console . log ( "✅ Connected to existing sandbox" ) ;
317+ safeResolve ( this ) ;
318+ } else {
319+ this . handleError (
320+ "Port is in use but sandbox is not responsive" ,
321+ "external-sandbox-check" ,
322+ safeReject ,
323+ ) ;
324+ }
243325 } )
244- . catch ( ( ) => {
326+ . catch ( ( err : any ) => {
245327 this . handleError (
246- "Port 8080 is in use but sandbox is not responsive" ,
328+ err ?. message ?? String ( err ) ,
247329 "external-sandbox-check" ,
248330 safeReject ,
249331 ) ;
@@ -289,10 +371,7 @@ class SandboxManager extends EventEmitter {
289371 for ( let attempt = 1 ; attempt <= maxRetries ; attempt ++ ) {
290372 try {
291373 // Try to connect to the Aztec node
292- const aztecNode = await createAztecNodeClient (
293- "http://localhost:8080" ,
294- { } ,
295- ) ;
374+ const aztecNode = await createAztecNodeClient ( this . url , { } ) ;
296375
297376 // Try to get node info to verify it's responsive
298377 const nodeInfo = await aztecNode . getNodeInfo ( ) ;
@@ -326,6 +405,11 @@ class SandboxManager extends EventEmitter {
326405 throw new Error ( "Cannot start sandbox - already running or starting" ) ;
327406 }
328407
408+ // If something is already running on the default URL, validate version and reuse it.
409+ if ( await this . tryConnectAndValidateRunningSandbox ( ) ) {
410+ return this ;
411+ }
412+
329413 return new Promise ( ( resolve , reject ) => {
330414 console . log ( "🚀 Starting Aztec sandbox" ) ;
331415 let resolved = false ; // Prevent double resolution
@@ -434,8 +518,7 @@ class SandboxManager extends EventEmitter {
434518 }
435519
436520 cleanup ( ) : void {
437- // Only kill process if we own it, not if using external sandbox
438- if ( ! this . isExternalSandbox && this . process ) {
521+ if ( this . process ) {
439522 this . process . kill ( "SIGTERM" ) ;
440523 }
441524
0 commit comments