UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

215 lines (212 loc) 6.78 kB
// 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 } from "@moonwall/util"; import { Context, Deferred, Effect, Layer, Option, Schedule } from "effect"; // 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/RpcPortDiscoveryService.ts var logger = createLogger({ name: "RpcPortDiscoveryService" }); var debug = logger.debug.bind(logger); var RpcPortDiscoveryService = class extends Context.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, Effect.map(parsePortsFromLsof), Effect.flatMap( (ports) => ports.length === 0 ? Effect.fail( new PortDiscoveryError({ cause: new Error("No listening ports found"), pid, attempts: 1 }) ) : Effect.succeed(ports) ), Effect.catchAll( (cause) => Effect.fail( new PortDiscoveryError({ cause, pid, attempts: 1 }) ) ) ); var testRpcMethod = (method) => Effect.flatMap( Deferred.make(), (responseDeferred) => Effect.flatMap( Socket.Socket, (socket) => Effect.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) => Effect.try(() => { const message = typeof data === "string" ? data : new TextDecoder().decode(data); const response = JSON.parse(message); return response.jsonrpc === "2.0" && !response.error; }).pipe( Effect.orElseSucceed(() => false), Effect.flatMap( (shouldSucceed) => shouldSucceed ? Deferred.succeed(responseDeferred, true) : Effect.void ) ) ); return Effect.all([ Effect.fork(handleMessages), Effect.flatMap(writer(request), () => Effect.void), Deferred.await(responseDeferred).pipe( Effect.timeoutOption("3 seconds"), Effect.map((opt) => opt._tag === "Some" ? opt.value : false) ) ]).pipe( Effect.map(([_, __, result]) => result), Effect.catchAll( (cause) => Effect.fail( new PortDiscoveryError({ cause, pid: 0, attempts: 1 }) ) ) ); }) ) ); var testRpcPort = (port, isEthereumChain) => Effect.scoped( Effect.provide( Effect.flatMap(testRpcMethod("system_chain"), (success) => { if (success) { debug(`Port ${port} responded to system_chain`); return Effect.succeed(port); } if (!isEthereumChain) { return Effect.fail( new PortDiscoveryError({ cause: new Error(`Port ${port} did not respond to system_chain`), pid: 0, attempts: 1 }) ); } return Effect.flatMap(testRpcMethod("eth_chainId"), (ethSuccess) => { if (ethSuccess) { debug(`Port ${port} responded to eth_chainId`); return Effect.succeed(port); } return Effect.fail( new PortDiscoveryError({ cause: new Error(`Port ${port} did not respond to eth_chainId`), pid: 0, attempts: 1 }) ); }); }), NodeSocket.layerWebSocket(`ws://localhost:${port}`) ) ).pipe( Effect.timeoutOption("7 seconds"), Effect.flatMap( (opt) => Option.match(opt, { onNone: () => Effect.fail( new PortDiscoveryError({ cause: new Error(`Port ${port} connection timeout`), pid: 0, attempts: 1 }) ), onSome: (val) => Effect.succeed(val) }) ) ); var discoverRpcPortWithRace = (config) => { const maxAttempts = config.maxAttempts || 2400; return getAllPorts(config.pid).pipe( Effect.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 Effect.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 Effect.raceAll( candidatePorts.map((port) => testRpcPort(port, config.isEthereumChain)) ).pipe( Effect.catchAll( (_error) => Effect.fail( new PortDiscoveryError({ cause: new Error(`All candidate ports failed RPC test: ${candidatePorts.join(", ")}`), pid: config.pid, attempts: 1 }) ) ) ); }), // Retry the entire discovery process Effect.retry( Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1))) ), Effect.catchAll( (error) => Effect.fail( new PortDiscoveryError({ cause: error, pid: config.pid, attempts: maxAttempts }) ) ), Effect.provide( NodeCommandExecutor.layer.pipe( Layer.provide(NodeContext.layer), Layer.provide(NodeFileSystem.layer) ) ) ); }; var RpcPortDiscoveryServiceLive = Layer.succeed(RpcPortDiscoveryService, { discoverRpcPort: discoverRpcPortWithRace }); export { RpcPortDiscoveryService, RpcPortDiscoveryServiceLive };