UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

483 lines (477 loc) 16.1 kB
// src/internal/effect/launchNodeEffect.ts import { Effect as Effect3, Layer as Layer3 } from "effect"; // src/internal/effect/ProcessManagerService.ts import { Context, Effect, Layer, pipe } from "effect"; import { Path } from "@effect/platform"; import { NodePath } from "@effect/platform-node"; import { spawn } from "child_process"; import * as fs from "fs"; // src/internal/effect/errors.ts import { Data } from "effect"; var PortDiscoveryError = class extends Data.TaggedError("PortDiscoveryError") { }; var NodeLaunchError = class extends Data.TaggedError("NodeLaunchError") { }; var NodeReadinessError = class extends Data.TaggedError("NodeReadinessError") { }; var ProcessError = class extends Data.TaggedError("ProcessError") { }; var StartupCacheError = class extends Data.TaggedError("StartupCacheError") { }; var FileLockError = class extends Data.TaggedError("FileLockError") { }; // src/internal/effect/ProcessManagerService.ts import { createLogger } from "@moonwall/util"; var logger = createLogger({ name: "ProcessManagerService" }); var ProcessManagerService = class extends Context.Tag("ProcessManagerService")() { }; var getLogPath = (config, pid) => pipe( Effect.all([Path.Path, Effect.sync(() => process.cwd())]), Effect.map(([pathService, cwd]) => { const dirPath = config.logDirectory || pathService.join(cwd, "tmp", "node_logs"); const portArg = config.args.find((a) => a.includes("port")); const port = portArg?.split("=")[1] || "unknown"; const baseName = pathService.basename(config.command); return pathService.join(dirPath, `${baseName}_node_${port}_${pid}.log`).replace(/node_node_undefined/g, "chopsticks"); }), Effect.provide(NodePath.layer), Effect.mapError( (cause) => new ProcessError({ cause, operation: "spawn" }) ) ); var ensureLogDirectory = (dirPath) => Effect.tryPromise({ try: async () => { try { await fs.promises.access(dirPath); } catch { await fs.promises.mkdir(dirPath, { recursive: true }); } }, catch: (cause) => new ProcessError({ cause, operation: "spawn" }) }); var spawnProcess = (config) => Effect.try({ try: () => { const child = spawn(config.command, [...config.args]); child.on("error", (error) => { logger.error(`Process spawn error: ${error}`); }); return child; }, catch: (cause) => new NodeLaunchError({ cause, command: config.command, args: [...config.args] }) }); var createLogStream = (logPath) => Effect.try({ try: () => fs.createWriteStream(logPath, { flags: "a" }), catch: (cause) => new ProcessError({ cause, operation: "spawn" }) }); var setupLogHandlers = (process2, logStream) => Effect.sync(() => { const logHandler = (chunk) => { logStream.write(chunk); }; process2.stdout?.on("data", logHandler); process2.stderr?.on("data", logHandler); }); var constructExitMessage = (process2, code, signal) => { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); if (process2.isMoonwallTerminating) { return `${timestamp} [moonwall] process killed. reason: ${process2.moonwallTerminationReason || "unknown"} `; } if (code !== null) { return `${timestamp} [moonwall] process closed with status code ${code} `; } if (signal !== null) { return `${timestamp} [moonwall] process terminated by signal ${signal} `; } return `${timestamp} [moonwall] process closed unexpectedly `; }; var setupExitHandler = (process2, logStream) => Effect.sync(() => { process2.once("close", (code, signal) => { const message = constructExitMessage(process2, code, signal); logStream.end(message); }); }); var killProcess = (process2, logStream, reason) => Effect.sync(() => { process2.isMoonwallTerminating = true; process2.moonwallTerminationReason = reason; }).pipe( Effect.flatMap( () => process2.pid ? Effect.try({ try: () => { process2.kill("SIGTERM"); }, catch: (cause) => new ProcessError({ cause, pid: process2.pid, operation: "kill" }) }) : Effect.sync(() => logStream.end()) ) ); var launchProcess = (config) => pipe( Effect.all([Path.Path, Effect.sync(() => config.logDirectory || void 0)]), Effect.flatMap(([pathService, customLogDir]) => { const dirPath = customLogDir || pathService.join(process.cwd(), "tmp", "node_logs"); return pipe( ensureLogDirectory(dirPath), Effect.flatMap(() => spawnProcess(config)), Effect.flatMap((childProcess) => { if (childProcess.pid === void 0) { return Effect.fail( new ProcessError({ cause: new Error("Process PID is undefined after spawn"), operation: "spawn" }) ); } return pipe( getLogPath(config, childProcess.pid), Effect.flatMap( (logPath) => pipe( createLogStream(logPath), Effect.flatMap( (logStream) => pipe( setupLogHandlers(childProcess, logStream), Effect.flatMap(() => setupExitHandler(childProcess, logStream)), Effect.map(() => { const processInfo = { process: childProcess, logPath }; const cleanup = pipe( killProcess(childProcess, logStream, "Manual cleanup requested"), Effect.catchAll( (error) => Effect.sync(() => { logger.error(`Failed to cleanly kill process: ${error}`); }) ) ); return { result: processInfo, cleanup }; }) ) ) ) ) ); }) ); }), Effect.provide(NodePath.layer) ); var ProcessManagerServiceLive = Layer.succeed(ProcessManagerService, { launch: launchProcess }); // src/internal/effect/RpcPortDiscoveryService.ts import { Command, Socket } from "@effect/platform"; import * as NodeCommandExecutor from "@effect/platform-node/NodeCommandExecutor"; import * as NodeContext from "@effect/platform-node/NodeContext"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { createLogger as createLogger2 } from "@moonwall/util"; import { Context as Context2, Deferred, Effect as Effect2, Layer as Layer2, Option, Schedule } from "effect"; var logger2 = createLogger2({ name: "RpcPortDiscoveryService" }); var debug = logger2.debug.bind(logger2); var RpcPortDiscoveryService = class extends Context2.Tag("RpcPortDiscoveryService")() { }; var parsePortsFromLsof = (stdout) => { const regex = /(?:.+):(\d+)/; return stdout.split("\n").flatMap((line) => { const match = line.match(regex); return match ? [Number.parseInt(match[1], 10)] : []; }); }; var getAllPorts = (pid) => Command.make("lsof", "-p", `${pid}`, "-n", "-P").pipe( Command.pipeTo(Command.make("grep", "LISTEN")), Command.string, Effect2.map(parsePortsFromLsof), Effect2.flatMap( (ports) => ports.length === 0 ? Effect2.fail( new PortDiscoveryError({ cause: new Error("No listening ports found"), pid, attempts: 1 }) ) : Effect2.succeed(ports) ), Effect2.catchAll( (cause) => Effect2.fail( new PortDiscoveryError({ cause, pid, attempts: 1 }) ) ) ); var testRpcMethod = (method) => Effect2.flatMap( Deferred.make(), (responseDeferred) => Effect2.flatMap( Socket.Socket, (socket) => Effect2.flatMap(socket.writer, (writer) => { const request = new TextEncoder().encode( JSON.stringify({ jsonrpc: "2.0", id: Math.floor(Math.random() * 1e4), method, params: [] }) ); const handleMessages = socket.runRaw( (data) => Effect2.try(() => { const message = typeof data === "string" ? data : new TextDecoder().decode(data); const response = JSON.parse(message); return response.jsonrpc === "2.0" && !response.error; }).pipe( Effect2.orElseSucceed(() => false), Effect2.flatMap( (shouldSucceed) => shouldSucceed ? Deferred.succeed(responseDeferred, true) : Effect2.void ) ) ); return Effect2.all([ Effect2.fork(handleMessages), Effect2.flatMap(writer(request), () => Effect2.void), Deferred.await(responseDeferred).pipe( Effect2.timeoutOption("3 seconds"), Effect2.map((opt) => opt._tag === "Some" ? opt.value : false) ) ]).pipe( Effect2.map(([_, __, result]) => result), Effect2.catchAll( (cause) => Effect2.fail( new PortDiscoveryError({ cause, pid: 0, attempts: 1 }) ) ) ); }) ) ); var testRpcPort = (port, isEthereumChain) => Effect2.scoped( Effect2.provide( Effect2.flatMap(testRpcMethod("system_chain"), (success) => { if (success) { debug(`Port ${port} responded to system_chain`); return Effect2.succeed(port); } if (!isEthereumChain) { return Effect2.fail( new PortDiscoveryError({ cause: new Error(`Port ${port} did not respond to system_chain`), pid: 0, attempts: 1 }) ); } return Effect2.flatMap(testRpcMethod("eth_chainId"), (ethSuccess) => { if (ethSuccess) { debug(`Port ${port} responded to eth_chainId`); return Effect2.succeed(port); } return Effect2.fail( new PortDiscoveryError({ cause: new Error(`Port ${port} did not respond to eth_chainId`), pid: 0, attempts: 1 }) ); }); }), NodeSocket.layerWebSocket(`ws://localhost:${port}`) ) ).pipe( Effect2.timeoutOption("7 seconds"), Effect2.flatMap( (opt) => Option.match(opt, { onNone: () => Effect2.fail( new PortDiscoveryError({ cause: new Error(`Port ${port} connection timeout`), pid: 0, attempts: 1 }) ), onSome: (val) => Effect2.succeed(val) }) ) ); var discoverRpcPortWithRace = (config) => { const maxAttempts = config.maxAttempts || 2400; return getAllPorts(config.pid).pipe( Effect2.flatMap((allPorts) => { debug(`Discovered ports: ${allPorts.join(", ")}`); const candidatePorts = allPorts.filter( (p) => p >= 1024 && p <= 65535 && p !== 30333 && p !== 9615 // Exclude p2p & metrics port ); if (candidatePorts.length === 0) { return Effect2.fail( new PortDiscoveryError({ cause: new Error(`No candidate RPC ports found in: ${allPorts.join(", ")}`), pid: config.pid, attempts: 1 }) ); } debug(`Testing candidate ports: ${candidatePorts.join(", ")}`); return Effect2.raceAll( candidatePorts.map((port) => testRpcPort(port, config.isEthereumChain)) ).pipe( Effect2.catchAll( (_error) => Effect2.fail( new PortDiscoveryError({ cause: new Error(`All candidate ports failed RPC test: ${candidatePorts.join(", ")}`), pid: config.pid, attempts: 1 }) ) ) ); }), // Retry the entire discovery process Effect2.retry( Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1))) ), Effect2.catchAll( (error) => Effect2.fail( new PortDiscoveryError({ cause: error, pid: config.pid, attempts: maxAttempts }) ) ), Effect2.provide( NodeCommandExecutor.layer.pipe( Layer2.provide(NodeContext.layer), Layer2.provide(NodeFileSystem.layer) ) ) ); }; var RpcPortDiscoveryServiceLive = Layer2.succeed(RpcPortDiscoveryService, { discoverRpcPort: discoverRpcPortWithRace }); // src/internal/effect/launchNodeEffect.ts import { createLogger as createLogger3 } from "@moonwall/util"; var logger3 = createLogger3({ name: "launchNodeEffect" }); var debug2 = logger3.debug.bind(logger3); var AllServicesLive = Layer3.mergeAll(ProcessManagerServiceLive, RpcPortDiscoveryServiceLive); async function launchNodeEffect(config) { const startTime = Date.now(); logger3.debug(`[T+0ms] Starting with command: ${config.command}, name: ${config.name}`); const nodeConfig = { isChopsticks: config.args.some((arg) => arg.includes("chopsticks.cjs")), hasRpcPort: config.args.some((arg) => arg.includes("--rpc-port")), hasPort: config.args.some((arg) => arg.includes("--port")) }; const finalArgs = !nodeConfig.isChopsticks && !nodeConfig.hasRpcPort ? [ ...config.args, // If MOONWALL_RPC_PORT was pre-allocated by LaunchCommandParser, respect it; otherwise fall back to 0. process.env.MOONWALL_RPC_PORT ? `--rpc-port=${process.env.MOONWALL_RPC_PORT}` : "--rpc-port=0" ] : config.args; debug2(`Final args: ${JSON.stringify(finalArgs)}`); const program = ProcessManagerService.pipe( Effect3.flatMap( (processManager) => Effect3.sync(() => logger3.debug(`[T+${Date.now() - startTime}ms] Launching process...`)).pipe( Effect3.flatMap( () => processManager.launch({ command: config.command, args: finalArgs, name: config.name }) ) ) ), Effect3.flatMap( ({ result: processResult, cleanup: processCleanup }) => Effect3.sync( () => logger3.debug( `[T+${Date.now() - startTime}ms] Process launched with PID: ${processResult.process.pid}` ) ).pipe( Effect3.flatMap(() => { const pid = processResult.process.pid; if (pid === void 0) { return Effect3.fail( new ProcessError({ cause: new Error("Process PID is undefined after launch"), operation: "check" }) ); } return RpcPortDiscoveryService.pipe( Effect3.flatMap( (rpcDiscovery) => Effect3.sync( () => logger3.debug( `[T+${Date.now() - startTime}ms] Discovering RPC port for PID ${pid}...` ) ).pipe( Effect3.flatMap( () => rpcDiscovery.discoverRpcPort({ pid, isEthereumChain: config.isEthereumChain, maxAttempts: 600 // Match PortDiscoveryService: 600 × 200ms = 120s }) ) ) ), Effect3.mapError( (error) => new ProcessError({ cause: error, pid, operation: "check" }) ), Effect3.flatMap( (port) => Effect3.sync( () => logger3.debug( `[T+${Date.now() - startTime}ms] Discovered and validated RPC port: ${port}` ) ).pipe( Effect3.map(() => ({ processInfo: { process: processResult.process, port, logPath: processResult.logPath }, cleanup: processCleanup })) ) ) ); }) ) ) ).pipe(Effect3.provide(AllServicesLive)); return Effect3.runPromise( Effect3.map(program, ({ processInfo, cleanup }) => ({ result: { runningNode: processInfo.process, port: processInfo.port, logPath: processInfo.logPath }, cleanup: () => Effect3.runPromise(cleanup) })) ); } export { launchNodeEffect };