@moonwall/cli
Version:
Testing framework for the Moon family of projects
483 lines (477 loc) • 16.1 kB
JavaScript
// 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
};