From 89d27fcd7633de3754ec878edf06a5298d3aabb2 Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:01:55 -0400 Subject: [PATCH] feat: run L1 anvil as a Docker compose service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the L1 anvil chain from a detached host process (plus a 100ms evm_mine heartbeat) into an `l1` Docker compose service so teardown via `docker compose down` is reliable — no orphaned anvil/heartbeat processes survive. - add `l1` compose service (foundry image) publishing 8545 so both host tooling (127.0.0.1:8545) and Nitro containers (host.docker.internal) reach it; state bind-mounted to config/anvil-state so snapshots work - replace startAnvilWithState() with startL1Container() (composeUp) - drop the pkill anvil/heartbeat calls from stop/clean/resetRuntime and the host pgrep check in status; compose down handles teardown --- apps/cli/src/commands/clean.ts | 4 -- apps/cli/src/commands/snapshot.ts | 14 ++++-- apps/cli/src/commands/status.ts | 7 +-- apps/cli/src/commands/stop.ts | 5 -- docker/docker-compose.yaml | 24 +++++++++ packages/core/src/init/chain-steps.ts | 10 ++-- packages/core/src/init/runner.ts | 14 ++---- packages/core/src/runtime.ts | 72 ++++++--------------------- 8 files changed, 58 insertions(+), 92 deletions(-) diff --git a/apps/cli/src/commands/clean.ts b/apps/cli/src/commands/clean.ts index 33cac68..31d5793 100644 --- a/apps/cli/src/commands/clean.ts +++ b/apps/cli/src/commands/clean.ts @@ -15,10 +15,6 @@ function stopAllServices(): void { console.log("[clean] Stopping Docker..."); composeDown({ composeFile: COMPOSE_FILE, projectName: "arbitrum-testnode" }); exec("docker", ["compose", "-f", COMPOSE_FILE, "-p", "arbitrum-testnode", "down", "-v"]); - - console.log("[clean] Killing Anvil..."); - exec("pkill", ["-f", "anvil.*--port.*8545"]); - exec("pkill", ["-f", "testnode-l1-heartbeat"]); } function removeConfigDirPreservingSnapshots(): void { diff --git a/apps/cli/src/commands/snapshot.ts b/apps/cli/src/commands/snapshot.ts index 5bc53d4..3603fdf 100644 --- a/apps/cli/src/commands/snapshot.ts +++ b/apps/cli/src/commands/snapshot.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import { waitForRpc } from "@arbitrum/testnode-core/docker.js"; import { - startAnvilWithState, + startL1Container, startNitroFromSnapshot, stopRuntime, } from "@arbitrum/testnode-core/runtime.js"; @@ -72,7 +72,11 @@ snapshotCli.command("build", { configDir: CONFIG_DIR, }); const manifest = captureSnapshot(CONFIG_DIR, COMPOSE_FILE, snapshotId); - startAnvilWithState(CONFIG_DIR); + startL1Container({ + composeFile: COMPOSE_FILE, + projectName: "arbitrum-testnode", + configDir: CONFIG_DIR, + }); await waitForRpc(RPCS.l1); await startNitroFromSnapshot( { @@ -102,7 +106,11 @@ snapshotCli.command("restore", { configDir: CONFIG_DIR, }); const manifest = restoreSnapshot(CONFIG_DIR, snapshotId); - startAnvilWithState(CONFIG_DIR); + startL1Container({ + composeFile: COMPOSE_FILE, + projectName: "arbitrum-testnode", + configDir: CONFIG_DIR, + }); await waitForRpc(RPCS.l1); await startNitroFromSnapshot( { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 7cab647..68bdb11 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,6 +1,5 @@ import { resolve } from "node:path"; import { isServiceRunning } from "@arbitrum/testnode-core/docker.js"; -import { exec } from "@arbitrum/testnode-core/exec.js"; import { isPidRunning, loadCurrentRun, @@ -27,12 +26,8 @@ export const statusCli = Cli.create("status", { const state = loadState(CONFIG_DIR); const run = loadCurrentRun(CONFIG_DIR); - // Check Anvil (L1) — it runs on host, not in Docker - const anvilCheck = exec("pgrep", ["-f", "anvil.*--port.*8545"]); - const anvilRunning = anvilCheck.exitCode === 0; - const services: Record = { - anvil: anvilRunning, + anvil: isServiceRunning("l1", DOCKER_OPTS), sequencer: isServiceRunning("sequencer", DOCKER_OPTS), validator: isServiceRunning("validator", DOCKER_OPTS), l3node: isServiceRunning("l3node", DOCKER_OPTS), diff --git a/apps/cli/src/commands/stop.ts b/apps/cli/src/commands/stop.ts index e9d6800..9cf9e8f 100644 --- a/apps/cli/src/commands/stop.ts +++ b/apps/cli/src/commands/stop.ts @@ -1,6 +1,5 @@ import { resolve } from "node:path"; import { composeDown } from "@arbitrum/testnode-core/docker.js"; -import { exec } from "@arbitrum/testnode-core/exec.js"; import { stopCurrentRun } from "@arbitrum/testnode-core/run-logger.js"; import { Cli } from "incur"; import { findProjectRoot } from "../project-root.js"; @@ -20,10 +19,6 @@ export const stopCli = Cli.create("stop", { console.log("[stop] Stopping Docker services..."); composeDown(DOCKER_OPTS); - console.log("[stop] Killing Anvil..."); - exec("pkill", ["-f", "anvil.*--port.*8545"]); - exec("pkill", ["-f", "testnode-l1-heartbeat"]); - console.log("[stop] Done."); return { success: true }; }, diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 90b1c83..1eaa7f5 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,4 +1,24 @@ services: + # L1 anvil chain. 8545 is published so both host tooling (127.0.0.1:8545) and + # Nitro containers (host.docker.internal:8545) reach it. State persists to the + # bind-mounted config/anvil-state so snapshots work. + l1: + image: ghcr.io/foundry-rs/foundry:v1.3.5 + ports: + - "8545:8545" + entrypoint: ["anvil"] + command: + - --host=0.0.0.0 + - --port=8545 + - --block-time=1 + - --accounts=10 + - --balance=1000000 + - --mnemonic=indoor dish desk flag debris potato excuse depart ticket judge file exit + - --chain-id=1337 + - --state=/state + volumes: + - ../config/anvil-state:/state + sequencer: image: offchainlabs/nitro-node:v3.9.5-66e42c4 ports: @@ -23,6 +43,8 @@ services: - --ws.port=8548 extra_hosts: - "host.docker.internal:host-gateway" + depends_on: + - l1 l3node: image: offchainlabs/nitro-node:v3.9.5-66e42c4 @@ -84,6 +106,7 @@ services: - "host.docker.internal:host-gateway" depends_on: - sequencer + - l1 scripts: image: nitro-testnode-scripts:latest @@ -98,6 +121,7 @@ services: dockerfile: tokenbridge.Dockerfile depends_on: - sequencer + - l1 environment: - ARB_URL=http://sequencer:8547 - ETH_URL=http://host.docker.internal:8545 diff --git a/packages/core/src/init/chain-steps.ts b/packages/core/src/init/chain-steps.ts index f3e7443..d87933a 100644 --- a/packages/core/src/init/chain-steps.ts +++ b/packages/core/src/init/chain-steps.ts @@ -1,4 +1,3 @@ -import type { ChildProcess } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import type { Address } from "viem"; @@ -13,7 +12,7 @@ import { deployTestErc20 } from "../fee-token.js"; import { ZERO_ADDRESS } from "../init-helpers.js"; import { patchGeneratedL2NodeConfig, patchGeneratedL3NodeConfig } from "../node-config-patches.js"; import { inboxAbi, publicClient, rollupAbi, walletClient } from "../rpc.js"; -import { startAnvilWithState } from "../runtime.js"; +import { startL1Container } from "../runtime.js"; import { deployRollupViaSdk, prepareNodeConfigFromDeployment } from "../sdk-chain.js"; import { markStepDone } from "../state.js"; import { @@ -46,7 +45,6 @@ const CONTRACT_DEPLOYER_POLLING_INTERVAL_MS = 100; const CONTRACT_DEPLOYER_CREATE2_CONFIRMATIONS = 1; const WASM_MODULE_ROOT = "0xdb698a2576298f25448bc092e52cf13b1e24141c997135d70f217d674bbeb69a"; -let anvilProcess: ChildProcess | undefined; let contractDeployerImageBuilt = false; interface RollupCreatorDeployment { @@ -178,10 +176,8 @@ async function deployRollupCreatorViaDocker( function createL1Steps(runtime: InitRuntime): Record { return { "start-l1": async (state) => { - anvilProcess = startAnvilWithState(runtime.configDir); - return markStepDone(state, "start-l1", { - ...(anvilProcess?.pid ? { pid: anvilProcess.pid } : {}), - }); + startL1Container(runtime); + return markStepDone(state, "start-l1"); }, "wait-l1": async (state) => { await waitForRpc(L1_RPC); diff --git a/packages/core/src/init/runner.ts b/packages/core/src/init/runner.ts index 5adb56c..e8ea001 100644 --- a/packages/core/src/init/runner.ts +++ b/packages/core/src/init/runner.ts @@ -1,4 +1,3 @@ -import type { ChildProcess } from "node:child_process"; import { waitForRpc } from "../docker.js"; import { finishActiveRun, @@ -8,12 +7,7 @@ import { startRunLoggingFromEnv, updateRunStep, } from "../run-logger.js"; -import { - resetRuntime, - startAnvilWithState, - startNitroFromSnapshot, - stopRuntime, -} from "../runtime.js"; +import { resetRuntime, startL1Container, startNitroFromSnapshot, stopRuntime } from "../runtime.js"; import { installSnapshotRelease } from "../snapshot-release.js"; import { DEFAULT_SNAPSHOT_ID, @@ -33,8 +27,6 @@ const L1_RPC = "http://127.0.0.1:8545"; const L2_RPC = "http://127.0.0.1:8547"; const L3_RPC = "http://127.0.0.1:8549"; -let _anvilProcess: ChildProcess | undefined; - async function runInitLoop( runtime: InitRuntime, feeTokenDecimals?: number, @@ -243,7 +235,7 @@ async function runSnapshotRestoreFlow( }); restoreSnapshot(runtime.configDir, snapshotId); startRunLoggingFromEnv(runtime.configDir) ?? startInlineRunLogging(runtime.configDir, logArgs); - _anvilProcess = startAnvilWithState(runtime.configDir); + startL1Container(runtime); await waitForRpc(L1_RPC); await startNitroFromSnapshot( { @@ -281,7 +273,7 @@ async function finalizeFreshInit(runtime: InitRuntime, snapshotId: string, total configDir: runtime.configDir, }); const snapshot = captureSnapshot(runtime.configDir, runtime.composeFile, snapshotId); - _anvilProcess = startAnvilWithState(runtime.configDir); + startL1Container(runtime); await waitForRpc(L1_RPC); await startNitroFromSnapshot( { diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 34a138d..6fde3c0 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,9 +1,6 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { existsSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; -import process from "node:process"; -import { MNEMONIC } from "./accounts.js"; -import { composeUp, waitForRpc } from "./docker.js"; +import { composeDown, composeUp, waitForRpc } from "./docker.js"; import { exec } from "./exec.js"; import { SNAPSHOTS_DIRNAME, getAnvilStateDir } from "./snapshot.js"; @@ -19,19 +16,14 @@ export interface RuntimeOptions { configDir: string; } -const L1_HEARTBEAT_MARKER = "testnode-l1-heartbeat"; const STATIC_CONFIG_FILES = new Set([SNAPSHOTS_DIRNAME, "testnodes.json"]); export function stopRuntime(options: RuntimeOptions): void { - exec("docker", ["compose", "-f", options.composeFile, "-p", options.projectName, "down"]); - exec("pkill", ["-f", "anvil.*--port.*8545"]); - exec("pkill", ["-f", L1_HEARTBEAT_MARKER]); + composeDown({ composeFile: options.composeFile, projectName: options.projectName }); } export function resetRuntime(options: RuntimeOptions): void { exec("docker", ["compose", "-f", options.composeFile, "-p", options.projectName, "down", "-v"]); - exec("pkill", ["-f", "anvil.*--port.*8545"]); - exec("pkill", ["-f", L1_HEARTBEAT_MARKER]); if (!existsSync(options.configDir)) { return; @@ -45,52 +37,20 @@ export function resetRuntime(options: RuntimeOptions): void { } } -export function startAnvilWithState(configDir: string): ChildProcess { - const anvilProcess = spawn( - "anvil", - [ - "--host", - "0.0.0.0", - "--port", - "8545", - "--block-time", - "1", - "--accounts", - "10", - "--balance", - "1000000", - "--mnemonic", - MNEMONIC, - "--chain-id", - "1337", - "--state", - getAnvilStateDir(configDir), - ], - { stdio: "ignore", detached: true }, - ); - anvilProcess.unref(); - const heartbeatScript = ` -/* ${L1_HEARTBEAT_MARKER} */ -const rpcUrl = "http://127.0.0.1:8545"; -const payload = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "evm_mine", params: [] }); -const tick = async () => { - try { - await fetch(rpcUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: payload, - }); - } catch {} -}; -setInterval(tick, 100); -tick(); -`; - const heartbeat = spawn(process.execPath, ["-e", heartbeatScript], { - stdio: "ignore", - detached: true, +/** + * Start the L1 anvil chain as the `l1` Docker compose service. The host + * `config/anvil-state` dir is bind-mounted into the container, so it is created + * first to keep snapshot capture/restore working against the host fs. + */ +export function startL1Container(options: RuntimeOptions): void { + mkdirSync(getAnvilStateDir(options.configDir), { recursive: true }); + const result = composeUp(["l1"], { + composeFile: options.composeFile, + projectName: options.projectName, }); - heartbeat.unref(); - return anvilProcess; + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || "failed to start l1 service"); + } } export async function startNitroFromSnapshot(