Commit 94996437 authored by edy's avatar edy

fix(desktop): handle bundled gateway port conflicts

parent a38906e4
......@@ -998,7 +998,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const nextConfig = config ?? await configService.load();
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
const useBundledRuntime = runtimeStatus.activeMode === "bundled-runtime" && typeof runtimeGatewayConnection.url === "string";
const useBundledRuntime = typeof runtimeGatewayConnection.url === "string"
&& (
runtimeStatus.activeMode === "bundled-runtime"
|| (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState === "running")
);
if (useBundledRuntime) {
return {
......@@ -1404,9 +1408,25 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
config: undefined
};
const gatewayStatus = config.apiKeyConfigured || (config.setupMode === "employee-key" && config.runtimeMode !== "external-gateway")
let gatewayStatus = config.apiKeyConfigured || (config.setupMode === "employee-key" && config.runtimeMode !== "external-gateway")
? await gatewayClient.status().catch(() => null)
: null;
if (
runtimeStatus.processState === "running"
&& (runtimeStatus.activeMode === "bundled-runtime" || runtimeStatus.selectedMode === "bundled-runtime")
&& await shouldRefreshGatewayClient(config)
) {
try {
await reconfigureGatewayClient(config);
await connectGatewayClientWithRetry("bundled");
gatewayStatus = await gatewayClient.status().catch(() => null);
} catch (error) {
void startupLogger.warn("workspace-summary", "gateway-refresh-failed", "Workspace summary could not refresh bundled Gateway client.", {
error: error instanceof Error ? error.message : String(error)
});
gatewayStatus = await gatewayClient.status().catch(() => gatewayStatus);
}
}
const runtimeTelemetryStatus = runtimeCloudSupervisor.getStatus();
const baseChatSummary = buildChatSummary({
config,
......
......@@ -257,8 +257,22 @@ export function buildChatSummary(input: {
gatewayStatus
});
const packagedBundledRuntime = isPackaged;
const bundledRuntimeSelected = runtimeStatus.selectedMode === "bundled-runtime"
|| runtimeStatus.activeMode === "bundled-runtime";
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
const staleGatewayTarget = typeof runtimeStatus.gatewayUrl === "string"
&& typeof gatewayStatus?.url === "string"
&& gatewayStatus.url !== runtimeStatus.gatewayUrl;
const bundledRuntimeWaitingForGateway = bundledRuntimeSelected
&& (
warmupInFlight
|| runtimeStatus.processState === "starting"
|| (runtimeStatus.processState === "running" && staleGatewayTarget)
);
const bundledTransientGatewayError = bundledRuntimeWaitingForGateway
&& gatewayStatus?.state === "error"
&& isTransientLocalGatewayError(gatewayError);
if (!config.apiKeyConfigured && !shellReady) {
if (
......@@ -302,6 +316,10 @@ export function buildChatSummary(input: {
};
}
if (gatewayStatus?.state === "error" && bundledTransientGatewayError) {
return buildGatewayStartingSummary(gatewayStatus);
}
if (gatewayStatus?.state === "error") {
const gatewayErrorMessage = toStartupErrorMessage(gatewayError, "聊天服务连接失败,请稍后重试。");
return {
......@@ -397,6 +415,10 @@ export function buildChatSummary(input: {
};
}
if (gatewayStatus?.state === "error" && bundledTransientGatewayError) {
return buildGatewayStartingSummary(gatewayStatus);
}
if (gatewayStatus?.state === "error") {
const gatewayErrorMessage = toStartupErrorMessage(gatewayError, "聊天服务连接失败,请稍后重试。");
return {
......
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const ipcSource = readFileSync(new URL("../src/main/ipc.ts", import.meta.url), "utf8");
test("workspace summary refreshes bundled gateway client before computing startup state", () => {
assert.match(ipcSource, /const useBundledRuntime = typeof runtimeGatewayConnection\.url === "string"[\s\S]*runtimeStatus\.activeMode === "bundled-runtime"[\s\S]*runtimeStatus\.selectedMode === "bundled-runtime" && runtimeStatus\.processState === "running"/);
assert.match(ipcSource, /let gatewayStatus = config\.apiKeyConfigured/);
assert.match(ipcSource, /runtimeStatus\.processState === "running"/);
assert.match(ipcSource, /await shouldRefreshGatewayClient\(config\)/);
assert.match(ipcSource, /await reconfigureGatewayClient\(config\)/);
assert.match(ipcSource, /await connectGatewayClientWithRetry\("bundled"\)/);
assert.match(ipcSource, /gatewayStatus = await gatewayClient\.status\(\)\.catch\(\(\) => null\)/);
});
import test from "node:test"
import assert from "node:assert/strict"
import type {
AppConfig,
GatewayStatus,
RuntimeCloudStatus,
RuntimeStatus
} from "@qjclaw/shared-types"
import { buildChatSummary } from "../src/main/workspace-startup.ts"
function makeConfig(): AppConfig {
return {
setupMode: "employee-key",
provider: "openai",
baseUrl: "https://example.test/v1",
apiKeyConfigured: true,
gatewayTokenConfigured: false,
authTokenConfigured: false,
defaultModel: "test-model",
workspacePath: "/tmp/qjc-workspace",
gatewayUrl: "ws://127.0.0.1:18889",
cloudApiBaseUrl: "https://cloud.example.test",
runtimeCloudApiBaseUrl: "https://runtime.example.test",
runtimeMode: "bundled-runtime",
expertModelConfig: {
image: { baseUrl: "", apiKeyConfigured: false, modelId: "" },
video: { baseUrl: "", apiKeyConfigured: false, modelId: "" },
copywriting: { baseUrl: "https://example.test/v1", apiKeyConfigured: true, modelId: "test-model" },
digitalHuman: {
volcAccessKeyConfigured: false,
volcSecretKeyConfigured: false,
qiniuAccessKeyConfigured: false,
qiniuSecretKeyConfigured: false
}
},
douyinRuntimeConfig: {
videoAnalyzer: { baseUrl: "", apiKeyConfigured: false, modelId: "" },
replicationBrief: { baseUrl: "", apiKeyConfigured: false, modelId: "" },
vectcut: { baseUrl: "", fileBaseUrl: "", apiKeyConfigured: false }
},
xhsFeishuConfig: {
appIdConfigured: false,
appSecretConfigured: false,
appTokenConfigured: false,
tableIdConfigured: false
}
}
}
function makeRuntime(processState: RuntimeStatus["processState"], gatewayUrl = "ws://127.0.0.1:51079"): RuntimeStatus {
return {
requestedMode: "bundled-runtime",
selectedMode: "bundled-runtime",
activeMode: processState === "running" || processState === "starting" ? "bundled-runtime" : "external-gateway",
payloadState: "ready",
processState,
runtimeDir: "/runtime",
nodeExecutable: "/runtime/node/bin/node",
openClawEntry: "/runtime/openclaw/index.js",
defaultConfigPath: "/runtime/config/openclaw.json",
runtimeDataDir: "/runtime-data",
runtimeStateDir: "/runtime-data/state",
runtimeLogsDir: "/runtime-data/logs",
logFilePath: "/runtime-data/logs/runtime.log",
gatewayUrl,
gatewayTokenConfigured: true,
message: processState === "running" ? "Managed bundled runtime process is running and Gateway is ready." : "Runtime stopped.",
modeReason: "test",
detectedFiles: [],
missingFiles: []
}
}
function makeRuntimeCloud(): RuntimeCloudStatus {
return {
state: "ready",
baseUrl: "https://runtime.example.test",
apiKeyConfigured: true
}
}
function makeGatewayError(gatewayUrl = "ws://127.0.0.1:18889"): GatewayStatus {
const parsedGatewayUrl = new URL(gatewayUrl)
return {
state: "error",
url: gatewayUrl,
host: parsedGatewayUrl.hostname,
port: Number(parsedGatewayUrl.port),
version: "unknown",
transport: "websocket",
lastError: "Gateway closed during connect (1006)."
}
}
function summarize(input: {
runtimeStatus: RuntimeStatus;
gatewayStatus: GatewayStatus;
warmupInFlight?: boolean;
}) {
return buildChatSummary({
config: makeConfig(),
runtimeStatus: input.runtimeStatus,
runtimeCloudStatus: makeRuntimeCloud(),
gatewayStatus: input.gatewayStatus,
warmupInFlight: input.warmupInFlight ?? false,
runtimeCloudRefreshInFlight: false,
runtimeCloudConfigSyncInFlight: false,
isPackaged: false
})
}
test("stopped bundled runtime with stale transient gateway error does not stay in startup state", () => {
const summary = summarize({
runtimeStatus: makeRuntime("stopped"),
gatewayStatus: makeGatewayError()
})
assert.equal(summary.chatLaunchState, "error")
assert.equal(summary.startupPhase, "error")
})
test("running bundled runtime with stale transient gateway error remains in gateway startup state", () => {
const summary = summarize({
runtimeStatus: makeRuntime("running"),
gatewayStatus: makeGatewayError()
})
assert.equal(summary.chatLaunchState, "starting")
assert.equal(summary.startupPhase, "connecting-gateway")
})
test("running bundled runtime with transient gateway error on current target reports an error", () => {
const summary = summarize({
runtimeStatus: makeRuntime("running", "ws://127.0.0.1:51079"),
gatewayStatus: makeGatewayError("ws://127.0.0.1:51079")
})
assert.equal(summary.chatLaunchState, "error")
assert.equal(summary.startupPhase, "error")
})
test("starting bundled runtime with transient gateway error remains in gateway startup state", () => {
const summary = summarize({
runtimeStatus: makeRuntime("starting", "ws://127.0.0.1:51079"),
gatewayStatus: makeGatewayError("ws://127.0.0.1:51079")
})
assert.equal(summary.chatLaunchState, "starting")
assert.equal(summary.startupPhase, "connecting-gateway")
})
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/main/workspace-startup.ts", import.meta.url), "utf8");
test("bundled runtime transient gateway errors stay in starting state while runtime is not failed", () => {
assert.match(source, /const bundledRuntimeSelected = runtimeStatus\.selectedMode === "bundled-runtime"/);
assert.match(source, /const staleGatewayTarget = typeof runtimeStatus\.gatewayUrl === "string"[\s\S]*gatewayStatus\.url !== runtimeStatus\.gatewayUrl/);
assert.match(source, /const bundledRuntimeWaitingForGateway = bundledRuntimeSelected[\s\S]*warmupInFlight[\s\S]*runtimeStatus\.processState === "starting"[\s\S]*runtimeStatus\.processState === "running" && staleGatewayTarget/);
assert.match(source, /const bundledTransientGatewayError = bundledRuntimeWaitingForGateway[\s\S]*isTransientLocalGatewayError\(gatewayError\)/);
assert.match(source, /if \(gatewayStatus\?\.state === "error" && bundledTransientGatewayError\) \{[\s\S]*return buildGatewayStartingSummary\(gatewayStatus\)/);
});
......@@ -15,6 +15,7 @@
"build": "tsup --config tsup.config.ts",
"clean": "rimraf dist",
"lint": "tsc --noEmit",
"test": "corepack pnpm run build && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
......@@ -29,4 +30,4 @@
"tsup": "^8.3.5",
"typescript": "^5.7.3"
}
}
\ No newline at end of file
}
......@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import path from "node:path";
import { promisify } from "node:util";
import WebSocket from "ws";
......@@ -150,6 +151,51 @@ function buildGatewayUrl(config?: OpenClawGatewayConfigShape): string | undefine
return `ws://${host}:${port}`;
}
function getGatewayPortFromUrl(url?: string): number | undefined {
if (!url) {
return undefined;
}
try {
const parsed = new URL(url);
const port = Number(parsed.port);
return Number.isInteger(port) && port > 0 ? port : undefined;
} catch {
return undefined;
}
}
async function findAvailableLoopbackPort(excludedPort?: number): Promise<number> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const port = await new Promise<number>((resolve, reject) => {
const server = createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const selectedPort = typeof address === "object" && address ? address.port : undefined;
server.close((error) => {
if (error) {
reject(error);
return;
}
if (!selectedPort) {
reject(new Error("Could not resolve an available loopback port."));
return;
}
resolve(selectedPort);
});
});
});
if (!excludedPort || port !== excludedPort) {
return port;
}
}
throw new Error(`Could not find an available loopback port distinct from ${excludedPort}.`);
}
const PYTHON_RUNTIME_IMPORTS = [
["openpyxl", "openpyxl"],
["pandas", "pandas"],
......@@ -818,6 +864,7 @@ export class RuntimeManager extends EventEmitter {
private lastStderrLines: string[] = [];
private startPromise?: Promise<RuntimeStatus>;
private reusedExistingGateway = false;
private managedGatewayPortOverride?: number;
constructor(options: RuntimeManagerOptions) {
super();
this.vendorRuntimeDir = options.vendorRuntimeDir;
......@@ -1031,9 +1078,13 @@ export class RuntimeManager extends EventEmitter {
}
private async performStart(): Promise<RuntimeStatus> {
const previousGatewayConnection = this.gatewayConnection;
await this.detectRuntime();
if (this.child && this.child.exitCode === null && !this.child.killed) {
if (this.managedGatewayPortOverride && previousGatewayConnection.url) {
this.gatewayConnection = previousGatewayConnection;
}
this.appendLog(
"info",
this.runtimeStatus.processState === "running"
......@@ -1085,6 +1136,7 @@ export class RuntimeManager extends EventEmitter {
let managedConfigPath: string;
try {
this.managedGatewayPortOverride = undefined;
managedConfigPath = await this.prepareManagedConfig(paths, "init");
this.gatewayConnection = await this.readGatewayConnection(managedConfigPath);
} catch (error) {
......@@ -1110,16 +1162,32 @@ export class RuntimeManager extends EventEmitter {
return this.status();
}
let usingFallbackGatewayPort = false;
if (reusableGateway.lastError && !isGatewayProbeNoListener(reusableGateway.lastError)) {
this.reusedExistingGateway = false;
this.lastError = `Port already has a Gateway or service at ${this.gatewayConnection.url ?? "unknown"}, but it is not reusable: ${reusableGateway.lastError}`;
this.appendLog("error", this.lastError);
this.refreshStatus("error");
return this.status();
const blockedGatewayUrl = this.gatewayConnection.url ?? "unknown";
const blockedPort = getGatewayPortFromUrl(this.gatewayConnection.url);
let fallbackPort: number;
try {
fallbackPort = await findAvailableLoopbackPort(blockedPort);
this.managedGatewayPortOverride = fallbackPort;
managedConfigPath = await this.prepareManagedConfig(paths, "init", fallbackPort);
this.gatewayConnection = await this.readGatewayConnection(managedConfigPath);
usingFallbackGatewayPort = true;
} catch (error) {
this.lastError = `Port already has a Gateway or service at ${blockedGatewayUrl}, but it is not reusable: ${reusableGateway.lastError}`;
this.appendLog("error", `${this.lastError} Failed to select a fallback port: ${formatExecError(error)}`);
this.refreshStatus("error");
return this.status();
}
this.appendLog(
"warn",
`Port already has a Gateway or service at ${blockedGatewayUrl}, but it is not reusable: ${reusableGateway.lastError}. Starting bundled runtime on fallback port ${fallbackPort}.`
);
}
this.reusedExistingGateway = false;
if (reusableGateway.lastError) {
if (reusableGateway.lastError && !usingFallbackGatewayPort) {
this.appendLog("info", `No reusable Gateway is active at ${this.gatewayConnection.url ?? "unknown"}: ${reusableGateway.lastError}`);
}
......@@ -1230,6 +1298,7 @@ export class RuntimeManager extends EventEmitter {
if ((!this.child || this.child.exitCode !== null || this.child.killed) && !this.managedChildPid) {
this.lastError = undefined;
this.reusedExistingGateway = false;
this.managedGatewayPortOverride = undefined;
this.refreshStatus("stopped");
return this.status();
}
......@@ -1260,6 +1329,7 @@ export class RuntimeManager extends EventEmitter {
}
if (!this.child) {
this.lastError = undefined;
this.managedGatewayPortOverride = undefined;
}
this.refreshStatus(this.child ? "stopping" : "stopped");
return this.status();
......@@ -1443,7 +1513,7 @@ export class RuntimeManager extends EventEmitter {
}
}
private async prepareManagedConfig(paths: RuntimeResolvedPaths, action: RuntimeCloudFetchAction): Promise<string> {
private async prepareManagedConfig(paths: RuntimeResolvedPaths, action: RuntimeCloudFetchAction, gatewayPortOverride?: number): Promise<string> {
const raw = await readFile(paths.defaultConfigPath, "utf8");
const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")) as OpenClawConfigShape;
const workspacePath = path.join(paths.runtimeDataDir, "workspace");
......@@ -1464,7 +1534,13 @@ export class RuntimeManager extends EventEmitter {
...gateway,
mode: "local",
bind: "loopback",
port: typeof gateway.port === "number" ? gateway.port : 18889,
port: typeof gatewayPortOverride === "number"
? gatewayPortOverride
: typeof this.managedGatewayPortOverride === "number"
? this.managedGatewayPortOverride
: typeof gateway.port === "number"
? gateway.port
: 18889,
auth: {
...(gateway.auth ?? {}),
mode: "token",
......
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { createServer as createHttpServer } from "node:http";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { RuntimeManager } from "../dist/index.js";
const require = createRequire(import.meta.url);
const wsModulePath = require.resolve("ws");
function listen(server, host = "127.0.0.1", port = 0) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
function serverPort(server) {
const address = server.address();
assert.equal(typeof address, "object");
assert.ok(address);
return address.port;
}
async function linkNodeExecutable(targetPath) {
try {
await symlink(process.execPath, targetPath);
} catch {
await copyFile(process.execPath, targetPath);
}
if (process.platform !== "win32") {
await chmod(targetPath, 0o755);
}
}
async function createFakeRuntime(rootDir, preferredPort) {
const runtimeDir = path.join(rootDir, "runtime");
const runtimeDataDir = path.join(rootDir, "data");
await mkdir(path.join(runtimeDir, "node", "bin"), { recursive: true });
await mkdir(path.join(runtimeDir, "openclaw", "package"), { recursive: true });
await mkdir(path.join(runtimeDir, "config"), { recursive: true });
await mkdir(path.join(runtimeDir, "python", "bin"), { recursive: true });
await linkNodeExecutable(path.join(runtimeDir, "node", "bin", "node"));
await writeFile(path.join(runtimeDir, "runtime-manifest.json"), "{}", "utf8");
await writeFile(path.join(runtimeDir, "openclaw", "package", "openclaw.mjs"), "export {};\n", "utf8");
await writeFile(path.join(runtimeDir, "python", "python-manifest.json"), JSON.stringify({
requestedPackages: [
"openpyxl", "pandas", "requests", "beautifulsoup4", "lxml", "urllib3", "pypdf",
"python-docx", "charset-normalizer", "pyyaml", "pillow", "loguru", "pyexecjs",
"python-dotenv", "certifi", "openai", "retry", "fastapi", "uvicorn",
"python-multipart", "pdfplumber", "greenlet", "playwright", "edge-tts",
"imageio-ffmpeg", "qiniu"
].map((name) => ({ name }))
}), "utf8");
const fakePythonPath = path.join(runtimeDir, "python", "bin", "python3");
await writeFile(fakePythonPath, [
"#!/usr/bin/env node",
"console.log(JSON.stringify({ ready: true, pythonVersion: '3.12.0', installedPackages: [], missingModules: [], importErrors: {} }));"
].join("\n"), "utf8");
if (process.platform !== "win32") {
await chmod(fakePythonPath, 0o755);
}
await writeFile(path.join(runtimeDir, "config", "openclaw.json"), JSON.stringify({
gateway: {
mode: "local",
bind: "loopback",
port: preferredPort,
auth: {
mode: "token",
token: "test-token"
}
}
}, null, 2), "utf8");
const gatewayScript = `
const { WebSocketServer } = require(${JSON.stringify(wsModulePath)});
const args = process.argv;
const portIndex = args.indexOf("--port");
const tokenIndex = args.indexOf("--token");
const port = Number(args[portIndex + 1]);
const token = tokenIndex >= 0 ? args[tokenIndex + 1] : undefined;
if (!Number.isFinite(port) || port <= 0) {
console.error("missing port");
process.exit(1);
}
const server = new WebSocketServer({ host: "127.0.0.1", port });
server.on("connection", (socket) => {
socket.send(JSON.stringify({ event: "connect.challenge", payload: { nonce: "test" } }));
socket.on("message", (data) => {
const frame = JSON.parse(data.toString());
if (frame.method === "connect") {
socket.send(JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: { server: { version: "test" }, features: { methods: ["status", "health"] } }
}));
return;
}
if (frame.method === "status") {
socket.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { runtimeVersion: "test" } }));
return;
}
if (frame.method === "health") {
socket.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { ok: true } }));
return;
}
socket.send(JSON.stringify({ type: "res", id: frame.id, ok: false, error: { message: "unknown method" } }));
});
});
process.on("SIGTERM", () => server.close(() => process.exit(0)));
setInterval(() => {}, 1000);
`;
await writeFile(path.join(runtimeDir, "openclaw", "index.js"), gatewayScript, "utf8");
return { runtimeDir, runtimeDataDir };
}
test("managed runtime falls back to a free loopback port when configured port is occupied by a non-reusable service", async () => {
const blocker = createHttpServer((_, response) => {
response.writeHead(200, { "content-type": "text/plain" });
response.end("not openclaw");
});
await listen(blocker);
const occupiedPort = serverPort(blocker);
const rootDir = await mkdtemp(path.join(tmpdir(), "qjc-runtime-port-fallback-"));
let manager;
try {
const { runtimeDir, runtimeDataDir } = await createFakeRuntime(rootDir, occupiedPort);
manager = new RuntimeManager({
vendorRuntimeDir: runtimeDir,
runtimeDataDir,
logFilePath: path.join(runtimeDataDir, "logs", "runtime.log"),
requestedMode: "bundled-runtime"
});
const status = await manager.start();
assert.equal(status.processState, "running");
assert.ok(status.gatewayUrl);
assert.notEqual(Number(new URL(status.gatewayUrl).port), occupiedPort);
const managedConfig = JSON.parse(await readFile(path.join(runtimeDataDir, "state", "openclaw.runtime.json"), "utf8"));
assert.equal(managedConfig.gateway.port, Number(new URL(status.gatewayUrl).port));
const syncedStatus = await manager.syncManagedConfig("sync");
assert.equal(syncedStatus.gatewayUrl, status.gatewayUrl);
const repeatedStartStatus = await manager.start();
assert.equal(repeatedStartStatus.gatewayUrl, status.gatewayUrl);
const logs = await manager.tailLogs();
assert.match(logs.map((entry) => entry.message).join("\n"), /fallback port/i);
} finally {
if (manager) {
await manager.stop();
}
await closeServer(blocker);
await rm(rootDir, { recursive: true, force: true });
}
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment