mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-11 08:38:48 +00:00
fix(dsh): close the stale-service and probe follow-ups
This commit is contained in:
parent
9f35f0c434
commit
cf043bd098
10 changed files with 323 additions and 54 deletions
|
|
@ -71,8 +71,9 @@ The installation is a Host-owned job:
|
|||
- Progress is polled by job id, so a second installation for another interpreter cannot replace what you see.
|
||||
- **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually
|
||||
exited.
|
||||
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place; install and
|
||||
service logs are rotated to `.log.1` once they pass 5 MB, and only the newest 20 install logs are kept.
|
||||
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place. Each install
|
||||
opens a new file and only the newest 20 are kept; the long-lived service log rotates to `service.log.1` once it
|
||||
passes 5 MB.
|
||||
- When the plugin unloads, running jobs are stopped.
|
||||
|
||||
## Workspace `.env`
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列
|
|||
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
|
||||
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
|
||||
- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。
|
||||
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`,
|
||||
并且目录只保留最近 20 个安装日志。
|
||||
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部。每次安装都会新建文件,目录只保留最近
|
||||
20 个;长期运行的服务日志超过 5 MB 后轮转为 `service.log.1`。
|
||||
- 插件卸载时,仍在运行的安装会被停止。
|
||||
|
||||
## Workspace `.env`
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export const envEn = {
|
|||
"The previous service could not be stopped; check the log.",
|
||||
startErrorUnverifiedRecord:
|
||||
"The recorded service identity could not be verified, so it was left untouched; stop it manually, then retry.",
|
||||
startErrorUnverifiedRecordPid:
|
||||
"The recorded service identity could not be verified (PID {pid}), so it was left untouched; stop it manually, then retry.",
|
||||
startErrorNoPython: "Select a Python interpreter first.",
|
||||
startErrorNotInstalled:
|
||||
"reme-ai is not installed in the selected interpreter.",
|
||||
|
|
@ -301,6 +303,8 @@ export const envZh: typeof envEn = {
|
|||
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
|
||||
startErrorUnverifiedRecord:
|
||||
"无法验证记录中的进程身份,已保持其运行;请手动停止后重试。",
|
||||
startErrorUnverifiedRecordPid:
|
||||
"无法验证记录中的进程身份(PID {pid}),已保持其运行;请手动停止后重试。",
|
||||
startErrorNoPython: "请先选择一个 Python 解释器。",
|
||||
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
|
||||
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function PythonSection({
|
|||
{detection.error ? `: ${detection.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{detection.phase === "error" ||
|
||||
{(detection.phase === "error" && !detected) ||
|
||||
(detection.phase === "ready" && envs.length === 0) ? (
|
||||
<div className="reme-env-hint">
|
||||
<strong>{t("emptyTitle")}</strong>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ const START_ERROR_KEYS: Record<string, EnvKey> = {
|
|||
};
|
||||
|
||||
function startErrorText(t: EnvTranslator, result: ServiceStartResult): string {
|
||||
// The unverified record carries the pid it refused to touch, so the message
|
||||
// can tell the user what to look for instead of a generic sentence.
|
||||
if (result.code === "unverified-record" && result.pid !== undefined)
|
||||
return fill(t("startErrorUnverifiedRecordPid"), { pid: result.pid });
|
||||
const key = result.code ? START_ERROR_KEYS[result.code] : undefined;
|
||||
return key ? t(key) : result.error || t("serviceError");
|
||||
}
|
||||
|
|
@ -221,7 +225,11 @@ export function useService(
|
|||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the reported start error.
|
||||
// A switch of endpoint aborts this call; its error belongs to the
|
||||
// old endpoint and must not be painted (or bump the sequence) on
|
||||
// the new card.
|
||||
if (controller.signal.aborted) return;
|
||||
// Otherwise fall back to the reported start error.
|
||||
}
|
||||
}
|
||||
applyState({ phase: "error", error: startErrorText(t, started) });
|
||||
|
|
@ -446,6 +454,12 @@ export function ServiceSection({
|
|||
}): JSX.Element {
|
||||
const { state, stopping, stopError, log } = control;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
// The confirmation belongs to one phase and endpoint: keeping it open across
|
||||
// a transition would re-show "Stop?" on top of a live service later.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setConfirming(false);
|
||||
}, [endpoint, state.phase]);
|
||||
// A conflict can still be our own unhealthy service (booting, wrong token);
|
||||
// offer the same Stop affordance instead of leaving the tab without a way
|
||||
// out other than editing the record by hand. `stopped`/`starting` with a
|
||||
|
|
@ -463,6 +477,11 @@ export function ServiceSection({
|
|||
state.data?.managed === true ||
|
||||
((state.phase === "stopped" || state.phase === "starting") &&
|
||||
state.data?.pid !== undefined);
|
||||
// A stale record may be a recycled pid that now belongs to a foreign
|
||||
// process. Stop then refuses, so Start must stay available: the Host stops a
|
||||
// provably owned previous launch, discards a foreign record, and reports an
|
||||
// unverifiable one. Hiding Start would leave that card without a way out.
|
||||
const canRestart = state.phase === "stopped" && state.data?.pid !== undefined;
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
|
|
@ -584,7 +603,8 @@ export function ServiceSection({
|
|||
{t("stopService")}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
) : null}
|
||||
{!canStop || canRestart ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
|
|
@ -599,7 +619,7 @@ export function ServiceSection({
|
|||
>
|
||||
{t("startService")}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
|
|
|
|||
|
|
@ -258,11 +258,17 @@ export class ReMeEnvManager {
|
|||
/** Tail of the workspace service log. */
|
||||
serviceLog(workspaceDir: string): ServiceLog {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
const logPath = join(dir, "service.log");
|
||||
const currentPath = join(dir, "service.log");
|
||||
const current = tailLog(currentPath, 40);
|
||||
if (current) return { logPath: currentPath, lines: current };
|
||||
// Rotation moves the previous launch's crash output to `service.log.1`; if
|
||||
// the current file is still empty, that backup is the only diagnostic.
|
||||
const lines = tailLog(logPath, 40) || tailLog(`${logPath}.1`, 40);
|
||||
return { logPath, lines };
|
||||
// the current file is still empty, that backup is the only diagnostic. The
|
||||
// returned path is the file actually read, so the UI labels it correctly.
|
||||
const rotatedPath = `${currentPath}.1`;
|
||||
const rotated = tailLog(rotatedPath, 40);
|
||||
return rotated
|
||||
? { logPath: rotatedPath, lines: rotated }
|
||||
: { logPath: currentPath, lines: "" };
|
||||
}
|
||||
|
||||
/** DSH-startup best effort: start a stopped local service when configured. */
|
||||
|
|
@ -438,8 +444,8 @@ export class ReMeEnvManager {
|
|||
command: "",
|
||||
logPath,
|
||||
startedAt: now,
|
||||
error:
|
||||
"The recorded service identity could not be verified; leaving it untouched",
|
||||
pid: recorded.pid,
|
||||
error: `The recorded service for ${recorded.endpoint} (pid ${recorded.pid}) could not be verified; leaving it untouched`,
|
||||
code: "unverified-record",
|
||||
};
|
||||
removeServiceState(this.serviceStatePath);
|
||||
|
|
@ -480,8 +486,8 @@ export class ReMeEnvManager {
|
|||
command: "",
|
||||
logPath,
|
||||
startedAt: now,
|
||||
error:
|
||||
"The recorded service identity could not be verified; leaving it untouched",
|
||||
pid: current.pid,
|
||||
error: `The recorded service for ${endpointValue} (pid ${current.pid}) could not be verified; leaving it untouched`,
|
||||
code: "unverified-record",
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -210,8 +210,11 @@ function alive(io: ServiceIO, pid?: number): boolean {
|
|||
try {
|
||||
io.probePid(pid);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (error) {
|
||||
// Only ESRCH proves the process is gone. EPERM means it exists but this
|
||||
// user may not signal it (for example a service DSH started elevated);
|
||||
// treating that as dead would drop the record and orphan the process.
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -739,8 +742,7 @@ export function createServiceIO(): ServiceIO {
|
|||
child.unref();
|
||||
}
|
||||
}
|
||||
if (child === undefined) throw new Error("spawn did not start");
|
||||
return { pid: child.pid };
|
||||
return { pid: child?.pid };
|
||||
},
|
||||
killTree(pid, force = false) {
|
||||
if (!isValidPid(pid)) return;
|
||||
|
|
|
|||
|
|
@ -267,7 +267,9 @@ test("shows the rotated service log when the current one is empty", (t) => {
|
|||
writeFileSync(join(directory, "service.log"), "");
|
||||
writeFileSync(join(directory, "service.log.1"), "Traceback: boom\n");
|
||||
const log = manager.serviceLog(directory);
|
||||
assert.equal(log.logPath, join(directory, "service.log"));
|
||||
// The path names the file actually read, so the UI cannot label a rotated
|
||||
// backup as the live log.
|
||||
assert.equal(log.logPath, join(directory, "service.log.1"));
|
||||
assert.match(log.lines, /boom/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -316,6 +316,28 @@ test("a dead record is cleared instead of reporting a stale pid", async (t) => {
|
|||
assert.equal(readServiceState(statePath), undefined);
|
||||
});
|
||||
|
||||
test("a probe denied by permissions is not treated as a dead process", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-eperm-");
|
||||
record(statePath);
|
||||
const denied = new Error("operation not permitted");
|
||||
denied.code = "EPERM";
|
||||
const { io } = fakeIO({
|
||||
probePid() {
|
||||
throw denied;
|
||||
},
|
||||
});
|
||||
const status = await serviceStatus(io, {
|
||||
endpoint: ENDPOINT,
|
||||
statePath,
|
||||
logPath: join(directory, "service.log"),
|
||||
});
|
||||
// The process exists but this user cannot signal it (e.g. an elevated
|
||||
// service): the record must survive so it is not orphaned.
|
||||
assert.equal(status.phase, "stopped");
|
||||
assert.equal(status.pid, 777);
|
||||
assert.equal(readServiceState(statePath)?.pid, 777);
|
||||
});
|
||||
|
||||
test("starts detached with the GBK-safe environment and records state", async (t) => {
|
||||
const { directory, statePath } = tempState(t, "reme-start-");
|
||||
const { io, calls } = fakeIO();
|
||||
|
|
@ -442,66 +464,88 @@ test("refuses to stop a process whose identity cannot be verified", async (t) =>
|
|||
async function listening(handler, t) {
|
||||
const server = createServer(handler);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => server.close());
|
||||
t.after(() => {
|
||||
// fetch keeps pooled sockets alive; close them so the next test is not
|
||||
// racing a server that is still draining.
|
||||
server.closeAllConnections?.();
|
||||
server.close();
|
||||
});
|
||||
const address = server.address();
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows may keep a just-killed process's cwd/log handle for a moment, so an
|
||||
* immediate recursive remove can fail with EBUSY and fail the whole test.
|
||||
*/
|
||||
async function removeDirectory(path) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = error?.code;
|
||||
if (
|
||||
!["EBUSY", "ENOTEMPTY", "EPERM", "EACCES"].includes(code) ||
|
||||
attempt === 19
|
||||
)
|
||||
return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("real IO accepts only a ReMe health report", async (t) => {
|
||||
const reme = await listening((request, response) => {
|
||||
// One server for every case: several servers plus the 3 s probe timeout made
|
||||
// this test fail under parallel load.
|
||||
let authorization = "";
|
||||
const base = await listening((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
if (request.url === "/health_check")
|
||||
const path = String(request.url ?? "").replace(/\/health_check$/, "");
|
||||
if (path === "/reme")
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
metadata: { health: { version: "0.4.2.1", healthy: true } },
|
||||
}),
|
||||
);
|
||||
else response.end("{}");
|
||||
}, t);
|
||||
const generic = await listening((_request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end("{}");
|
||||
else if (path === "/failing") {
|
||||
response.statusCode = 503;
|
||||
response.end("{}");
|
||||
} else if (path === "/unhappy")
|
||||
response.end(JSON.stringify({ success: false }));
|
||||
else if (path === "/guarded") {
|
||||
authorization = String(request.headers.authorization ?? "");
|
||||
response.end("{}");
|
||||
} else response.end("{}");
|
||||
}, t);
|
||||
|
||||
const io = createServiceIO();
|
||||
const good = await io.fetchHealth(reme, "");
|
||||
const good = await io.fetchHealth(`${base}/reme`, "");
|
||||
assert.equal(good.ok, true);
|
||||
assert.equal(good.version, "0.4.2.1");
|
||||
assert.equal(good.code, undefined);
|
||||
|
||||
const empty = await io.fetchHealth(generic, "");
|
||||
const empty = await io.fetchHealth(`${base}/generic`, "");
|
||||
assert.equal(empty.ok, false);
|
||||
assert.match(empty.error, /ReMe health report/);
|
||||
// The client localizes the code instead of showing this English sentence.
|
||||
assert.equal(empty.code, "not-reme");
|
||||
|
||||
const failing = await listening((_request, response) => {
|
||||
response.statusCode = 503;
|
||||
response.end("{}");
|
||||
}, t);
|
||||
const httpError = await io.fetchHealth(failing, "");
|
||||
const httpError = await io.fetchHealth(`${base}/failing`, "");
|
||||
assert.equal(httpError.code, "http-status");
|
||||
|
||||
const unhappy = await listening((_request, response) => {
|
||||
response.end(JSON.stringify({ success: false }));
|
||||
}, t);
|
||||
const reported = await io.fetchHealth(unhappy, "");
|
||||
const reported = await io.fetchHealth(`${base}/unhappy`, "");
|
||||
assert.equal(reported.code, "reme-error");
|
||||
|
||||
// The Authorization header is actually sent when a token is configured.
|
||||
let authorization = "";
|
||||
const guarded = await listening((request, response) => {
|
||||
authorization = String(request.headers.authorization ?? "");
|
||||
response.end("{}");
|
||||
}, t);
|
||||
await io.fetchHealth(guarded, "secret-token");
|
||||
await io.fetchHealth(`${base}/guarded`, "secret-token");
|
||||
assert.equal(authorization, "Bearer secret-token");
|
||||
});
|
||||
|
||||
test("real IO spawns, identifies and stops a detached process", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-real-service-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
t.after(() => removeDirectory(directory));
|
||||
const io = createServiceIO();
|
||||
const { pid } = await io.spawnService({
|
||||
exe: process.execPath,
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ async function mount(overrides = {}, options = {}) {
|
|||
ok: true,
|
||||
value:
|
||||
typeof value === "function"
|
||||
? value(payload?.args?.input)
|
||||
? value(payload?.args?.input, signal)
|
||||
: value ?? null,
|
||||
};
|
||||
},
|
||||
|
|
@ -769,14 +769,67 @@ test("a live record that never listened offers Stop instead of 'Starting'", asyn
|
|||
assert.equal(copy.includes(envZh.serviceBadgeStarting), false);
|
||||
assert.ok(copy.includes(envZh.serviceBadgeStale));
|
||||
assert.ok(copy.includes(envZh.staleNote));
|
||||
const start = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "button" &&
|
||||
textOf(node.props.children) === envZh.startService,
|
||||
);
|
||||
assert.equal(start.length, 0, "Start is replaced by the Stop way out");
|
||||
// Stop is the way out for a provably owned leftover...
|
||||
const stop = buttonByText(renderer, envZh.stopService);
|
||||
assert.equal(stop.props.disabled, false, "the recorded pid can be stopped");
|
||||
// ...and Start must stay available too: the pid may have been recycled by a
|
||||
// foreign process, where Stop refuses and only a restart self-heals.
|
||||
const start = buttonByText(renderer, envZh.startService);
|
||||
assert.equal(start.props.disabled, false, "Start can replace the record");
|
||||
});
|
||||
|
||||
test("Start replaces a stale record that belongs to a foreign pid", async (t) => {
|
||||
// The pid is alive but no longer our service, so Stop answers `foreign` and
|
||||
// keeps the record. Start must still be offered: the Host discards a foreign
|
||||
// record and launches a fresh service.
|
||||
const stale = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
pid: 6000,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const running = {
|
||||
phase: "running",
|
||||
managed: true,
|
||||
pid: 7000,
|
||||
version: "0.4.0.9",
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: stale.logPath,
|
||||
};
|
||||
let statusCalls = 0;
|
||||
const { renderer, calls, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": () => {
|
||||
statusCalls += 1;
|
||||
return statusCalls === 1 ? stale : running;
|
||||
},
|
||||
"remeEnv/serviceStart": {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
command: "",
|
||||
logPath: stale.logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
});
|
||||
assert.ok(
|
||||
calls.rpc.some((call) => call.method === "remeEnv/serviceStart"),
|
||||
"the restart really reached the Host",
|
||||
);
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes("ReMe 0.4.0.9"));
|
||||
assert.ok(copy.includes("PID 7000"));
|
||||
});
|
||||
|
||||
test("an owned conflict during a start keeps polling until healthy", async (t) => {
|
||||
|
|
@ -1066,3 +1119,140 @@ test("a Python refresh keeps the previous interpreter list on screen", async (t)
|
|||
assert.equal(copy.includes(envZh.emptyTitle), false);
|
||||
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 6);
|
||||
});
|
||||
|
||||
test("a failed Python refresh keeps the working list without the empty state", async (t) => {
|
||||
let pythonCalls = 0;
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/python": () => {
|
||||
pythonCalls += 1;
|
||||
if (pythonCalls === 1) return RESPONSES["remeEnv/python"];
|
||||
throw new Error("probe exploded");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.changeInterpreter).props.onClick();
|
||||
});
|
||||
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 6);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.refresh).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes(envZh.detectFailed));
|
||||
// A failed probe that still has the previous result must not claim that no
|
||||
// Python exists, nor offer Miniconda next to a valid interpreter list.
|
||||
assert.equal(copy.includes(envZh.emptyTitle), false);
|
||||
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 6);
|
||||
});
|
||||
|
||||
test("the conflict fallback of a start does not paint an aborted request", async (t) => {
|
||||
const OLD = "http://127.0.0.1:2333";
|
||||
const NEW = "http://127.0.0.1:2444";
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: OLD,
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const newRunning = {
|
||||
phase: "running",
|
||||
managed: true,
|
||||
pid: 1111,
|
||||
version: "9.9.9",
|
||||
endpoint: NEW,
|
||||
logPath: stopped.logPath,
|
||||
};
|
||||
let statusCalls = 0;
|
||||
const { renderer, setValue, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": (input, signal) => {
|
||||
if (input?.endpoint === NEW) return newRunning;
|
||||
statusCalls += 1;
|
||||
if (statusCalls === 1) return stopped;
|
||||
// The R3 re-query hangs until the endpoint switch aborts it.
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
"remeEnv/serviceStart": {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
error: "Another process is already listening on this endpoint",
|
||||
code: "conflict",
|
||||
command: "",
|
||||
logPath: stopped.logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
await act(async () => {
|
||||
setValue({ endpoint: NEW });
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes("ReMe 9.9.9"), "the new endpoint state is shown");
|
||||
// The old endpoint's conflict error must not land on the new card.
|
||||
assert.equal(copy.includes(envZh.startErrorConflict), false);
|
||||
});
|
||||
|
||||
test("an unverified record reports the pid it refused to touch", async (t) => {
|
||||
const stopped = {
|
||||
phase: "stopped",
|
||||
managed: false,
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
logPath: "C:\\Users\\test\\.reme\\service.log",
|
||||
};
|
||||
const { renderer, restore } = await mount(
|
||||
{},
|
||||
{
|
||||
responses: {
|
||||
"remeEnv/serviceStatus": stopped,
|
||||
"remeEnv/serviceStart": {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
error: "The recorded service identity could not be verified",
|
||||
code: "unverified-record",
|
||||
pid: 6000,
|
||||
command: "",
|
||||
logPath: stopped.logPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(restore);
|
||||
await act(async () => {
|
||||
buttonByText(renderer, envZh.startService).props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
const copy = texts(renderer.toJSON()).join(" ");
|
||||
assert.ok(copy.includes("PID 6000"));
|
||||
assert.ok(copy.includes("6000"));
|
||||
assert.equal(copy.includes("The recorded service identity"), false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue