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