fix(dsh): close the stale-service and probe follow-ups

This commit is contained in:
LiangshouX 2026-10-29 01:21:02 +08:00
parent 9f35f0c434
commit cf043bd098
10 changed files with 323 additions and 54 deletions

View file

@ -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`

View file

@ -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`

View file

@ -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 版本过低,请先升级。",

View file

@ -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>

View file

@ -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"

View file

@ -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 {

View file

@ -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;

View file

@ -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/);
});

View file

@ -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,

View file

@ -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);
});