From 1db40e8fff9b911e17d6912e094c470869a1f730 Mon Sep 17 00:00:00 2001
From: LiangshouX
Date: Thu, 29 Oct 2026 01:54:29 +0800
Subject: [PATCH] fix(dsh): let an unverifiable service record be forgotten
---
integrations/dsh/docs/environment.md | 16 +--
integrations/dsh/docs/environment_ZH.md | 11 +-
integrations/dsh/scripts/test-package.mjs | 39 +++++--
.../dsh/src/environment/client/locales.ts | 10 ++
.../dsh/src/environment/client/python.tsx | 23 +++-
.../dsh/src/environment/client/rpc.ts | 12 +++
.../dsh/src/environment/client/service.tsx | 100 +++++++++++++++++-
.../dsh/src/environment/host/gateway.ts | 13 +++
.../dsh/src/environment/host/manager.ts | 26 +++++
.../dsh/src/environment/host/service.ts | 36 +++++++
integrations/dsh/src/environment/types.ts | 12 +++
integrations/dsh/tests/env-gateway.test.mjs | 27 +++++
integrations/dsh/tests/env-manager.test.mjs | 65 ++++++++++++
integrations/dsh/tests/env-service.test.mjs | 11 +-
integrations/dsh/tests/env-tab.test.mjs | 78 +++++++++++++-
15 files changed, 444 insertions(+), 35 deletions(-)
diff --git a/integrations/dsh/docs/environment.md b/integrations/dsh/docs/environment.md
index 861764ff..6924d0c9 100644
--- a/integrations/dsh/docs/environment.md
+++ b/integrations/dsh/docs/environment.md
@@ -126,15 +126,17 @@ processes it manages when the Host exits. The plugin owns a small service adapte
same command by hand. A record for port 2333 never claims a service on port 2444.
- When the evidence cannot be read (localized `tasklist`/`ps`, missing permissions), ownership stays unproven. Start
and Stop both leave the process and the record alone: Stop refuses, and Start reports an error instead of dropping
- the record. Discarding it would orphan a live service that the tab could never stop again. Verify the process
- yourself, stop it, then retry — or remove `service.json` if it is really gone.
+ the record. Discarding it would orphan a live service that the tab could never stop again. The card then offers
+ **Forget this record**, which deletes only `service.json` and never signals the process; the user confirms what to do
+ with it themselves.
- A service that occupies the port but fails the health check is reported as a conflict; if it is provably this
plugin's service, the tab still offers **Stop**.
- **Stop** tree-kills the recorded process (process group on POSIX, `taskkill /T /F` on Windows), waits for it to
exit, escalates (POSIX: `SIGTERM` then `SIGKILL`; Windows: force `taskkill` again), and clears the record only
after the pid is gone. A service started elsewhere stays untouched and is only observed.
-- A recorded launch that is still alive but never became healthy is shown as **Last launch not ready**, with Stop as
- the way out; it is not shown as "Starting" unless a start from this tab is actually in flight.
+- A recorded launch that is still alive but never became healthy is shown as **Last launch not ready** with both
+ **Stop** and **Start**: Stop clears the record, while Start makes the Host stop a provably owned previous launch or
+ discard a proven-foreign one. It is not shown as "Starting" unless a start from this tab is actually in flight.
- **Start** is serialized: two requests cannot spawn two services, and a second request observes the launch
already in flight. Before replacing a recorded launch that never became healthy, Start only kills it when
ownership is proven; an unproven record is kept and reported, never silently dropped.
@@ -176,8 +178,10 @@ URL (Memory settings) or stop the other program.
appear there.
**The service shows “Last launch not ready”.** A launch recorded by the plugin is still alive but never answered
-`/health_check` (for example DSH closed while it was booting). Use **Stop** to clear it, then start again. If Stop
-reports that the identity cannot be verified, confirm the process yourself before ending it.
+`/health_check` (for example DSH closed while it was booting). Use **Stop** to clear it and start again. If the pid
+was recycled by another program, that record is provably foreign: **Start** discards it and launches a fresh service.
+If the identity cannot be verified at all, use **Forget this record** — it deletes only the plugin's record and
+leaves the process untouched.
**The service shows as foreign.** It was started outside the plugin, so it is observed but not managed. Stop it
in its own terminal if you want the tab to manage the next launch.
diff --git a/integrations/dsh/docs/environment_ZH.md b/integrations/dsh/docs/environment_ZH.md
index 153c7d5c..6cd272a7 100644
--- a/integrations/dsh/docs/environment_ZH.md
+++ b/integrations/dsh/docs/environment_ZH.md
@@ -104,14 +104,14 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把
workspace 路径、另一个端口都不会命中),但无法区分“用户照抄同一命令行手工启动”的同一服务。2333 端口上的记录绝不会
声称管理 2444 端口上的服务。
- 证据读不到时(`tasklist`/`ps` 输出本地化、权限不足等)归属保持“未证明”:启动与停止都不会碰进程、也不会丢弃记录——
- 停止会拒绝,启动会返回错误。丢弃记录会让一个仍在运行的进程变成标签页再也停不掉的孤儿;请自行确认该进程后结束它
- 再重试,若确实已消失则删除 `service.json`。
+ 停止会拒绝,启动会返回错误。丢弃记录会让一个仍在运行的进程变成标签页再也停不掉的孤儿;此时卡片会给出**忘记这条记录**,
+ 它只删除 `service.json`、绝不向进程发信号,由用户自行确认后续处理。
- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX:
`SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,
只会被观察。
-- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并给出**停止**按钮;只有本标签页发起的启动仍在进行时才会
- 显示“启动中”。
+- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并同时给出**停止**与**启动**:停止负责清记录,启动则让 Host
+ 停掉可证明归属的旧启动、或丢弃已证明属于外部的记录。只有本标签页发起的启动仍在进行时才会显示“启动中”。
- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有
归属被证明才会杀掉它;证明不了就保留记录并报错,不会静默丢弃。
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。
@@ -146,7 +146,8 @@ Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要
始终可用。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、`.env` 不合法、端口冲突都会体现在那里。
**服务显示“上次启动未就绪”。** 本插件记录的进程仍存活,但一直没有通过 `/health_check`(例如启动途中 DSH 被关闭)。点**停止**
-清掉它,再重新启动。若停止时提示无法验证进程身份,请先自行确认该进程再结束它。
+清掉它后重新启动。若 pid 已被其它程序复用,这条记录可证明属于外部:点**启动**会丢弃记录并拉起新服务。若完全无法验证身份,
+点**忘记这条记录**——它只删除本插件的记录,不会碰进程。
**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从
这里启动。
diff --git a/integrations/dsh/scripts/test-package.mjs b/integrations/dsh/scripts/test-package.mjs
index de77a6f1..6775cdb4 100644
--- a/integrations/dsh/scripts/test-package.mjs
+++ b/integrations/dsh/scripts/test-package.mjs
@@ -53,22 +53,43 @@ const npmCommand = process.env.npm_execpath
? [process.execPath, [process.env.npm_execpath]]
: ["npm", []];
-async function run(command, args, cwd) {
+async function run(command, args, cwd, label = "command") {
try {
return await execFileAsync(command, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
} catch (error) {
- process.stderr.write(error.stdout || "");
- process.stderr.write(error.stderr || "");
- throw error;
+ const stdout = String(error.stdout ?? "");
+ const stderr = String(error.stderr ?? "");
+ const combined = `${stdout}\n${stderr}`;
+ // The raw TAP buffer is far too large for a CI log and hides which step
+ // failed. Report the failing cases and a short tail instead.
+ const failures = combined
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => /^not ok \d+ - /.test(line) || line.startsWith("✖ "))
+ .slice(0, 20);
+ const tail = combined.trim().split("\n").slice(-40).join("\n");
+ process.stderr.write(
+ `\n[test-package] ${label} failed (exit ${error.code ?? "error"})\n`,
+ );
+ if (failures.length > 0)
+ process.stderr.write(
+ `[test-package] failing tests:\n${failures.join("\n")}\n`,
+ );
+ if (tail) process.stderr.write(`[test-package] last output:\n${tail}\n`);
+ // A short error keeps Node's unhandled-rejection dump readable; the large
+ // stdout/stderr buffers stay out of it.
+ const failure = new Error(`${label} failed: ${error.message}`);
+ failure.code = error.code;
+ throw failure;
}
}
/** Run the npm CLI on the platform this check runs on. */
-function runNpm(args, cwd) {
- return run(npmCommand[0], [...npmCommand[1], ...args], cwd);
+function runNpm(args, cwd, label = "npm") {
+ return run(npmCommand[0], [...npmCommand[1], ...args], cwd, label);
}
try {
@@ -81,6 +102,7 @@ try {
temporaryDirectory,
],
root,
+ "npm pack",
);
const [result] = JSON.parse(stdout);
const files = new Set(result.files.map(({ path: file }) => file));
@@ -140,6 +162,7 @@ try {
"--fetch-timeout=30000",
],
consumer,
+ `install DSH ${release.dsh}`,
);
await run(
process.execPath,
@@ -153,6 +176,7 @@ try {
`,
],
consumer,
+ `import DSH ${release.dsh}`,
);
const installed = path.join(consumer, "node_modules", manifest.name);
@@ -173,8 +197,9 @@ try {
process.execPath,
["--test", ...testFiles],
installed,
+ `tests DSH ${release.dsh}`,
);
- await runNpm(["run", "build"], installed);
+ await runNpm(["run", "build"], installed, `build DSH ${release.dsh}`);
const count = /# tests (\d+)/.exec(tested.stdout)?.[1];
process.stdout.write(
`DSH ${release.dsh}: packed import, source build, and ${count} tests passed\n`,
diff --git a/integrations/dsh/src/environment/client/locales.ts b/integrations/dsh/src/environment/client/locales.ts
index f5e2b9cc..4d8d441b 100644
--- a/integrations/dsh/src/environment/client/locales.ts
+++ b/integrations/dsh/src/environment/client/locales.ts
@@ -142,6 +142,11 @@ export const envEn = {
"This plugin's service holds the port but is not reporting healthy. Stop it to recover.",
staleNote:
"A recorded launch is still running but never became healthy. Stop it, then start again.",
+ forgetRecord: "Forget this record",
+ forgetHint:
+ "The plugin cannot clear this service record: Start and Stop both refuse to act on it. Forgetting it deletes only the record; the process keeps running.",
+ forgetConfirm:
+ "Forget the recorded service? This only deletes the plugin's record; it does not stop the process. Continue only if the process is already gone or you will manage it yourself.",
healthErrorHttp:
"The endpoint returned an HTTP error, not a ReMe health report.",
healthErrorNotReme:
@@ -319,6 +324,11 @@ export const envZh: typeof envEn = {
conflictManagedNote:
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
staleNote: "上次启动的进程仍在运行,但从未就绪;请停止它后重新启动。",
+ forgetRecord: "忘记这条记录",
+ forgetHint:
+ "本插件无法清除这条服务记录:启动与停止都会拒绝操作它。忘记记录只删除记录,进程仍会继续运行。",
+ forgetConfirm:
+ "忘记这条服务记录?此操作只删除本插件的记录,不会结束进程。请确认进程已结束或你将自行管理后再继续。",
healthErrorHttp: "服务地址返回了 HTTP 错误,而不是 ReMe 健康报告。",
healthErrorNotReme: "服务地址有响应,但返回的不是 ReMe 健康报告。",
healthErrorReme: "服务在健康检查中报告了错误。",
diff --git a/integrations/dsh/src/environment/client/python.tsx b/integrations/dsh/src/environment/client/python.tsx
index cd6cfd9d..7dcf1184 100644
--- a/integrations/dsh/src/environment/client/python.tsx
+++ b/integrations/dsh/src/environment/client/python.tsx
@@ -135,14 +135,18 @@ export function PythonSection({
const selectedEnv = selection.selectedEnv;
const others = envs.filter((env) => env.exe !== selectedPath);
+ // The selected interpreter stays in the radio group, checked and disabled,
+ // so assistive technology can hear which one is current. Dropping it from the
+ // group left every radio unchecked, which conveys no selection at all.
+ const listed = selectedEnv ? [selectedEnv, ...others] : others;
const filter = query.trim().toLowerCase();
const matches = filter
- ? others.filter((env) =>
+ ? listed.filter((env) =>
`${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}`
.toLowerCase()
.includes(filter),
)
- : others;
+ : listed;
const visible = showAll ? matches : matches.slice(0, ENV_PAGE);
const hidden = matches.length - visible.length;
const withReme = envs.filter((env) => env.reme?.installed === true).length;
@@ -271,15 +275,24 @@ export function PythonSection({
{
selection.select(env);
setListOpen(false);
}}
/>
{`Python ${env.version || "?"}`}
- {originLabel(t, env.origin)}
+
+ {env.exe === selectedPath
+ ? t("currentInterpreter")
+ : originLabel(t, env.origin)}
+ ;
stop(): Promise;
+ forget(): Promise;
refresh(): void;
log: ServiceLogView;
viewLog(): Promise;
@@ -106,6 +111,8 @@ export function useService(
const [state, setState] = useState({ phase: "checking" });
const [stopping, setStopping] = useState(false);
const [stopError, setStopError] = useState("");
+ const [canForget, setCanForget] = useState(false);
+ const [forgetting, setForgetting] = useState(false);
const [tick, setTick] = useState(0);
const [log, setLog] = useState({
open: false,
@@ -142,6 +149,8 @@ export function useService(
setState({ phase: "checking" });
setStopError("");
setStopping(false);
+ setCanForget(false);
+ setForgetting(false);
}, [endpoint]);
useEffect(() => () => startController.current?.abort(), []);
@@ -162,6 +171,10 @@ export function useService(
),
);
if (controller.signal.aborted || request !== statusSeq.current) return;
+ // The record is gone (dead pid cleared by the Host, or just forgotten),
+ // so there is nothing left to forget.
+ if (next.phase === "stopped" && next.pid === undefined)
+ setCanForget(false);
// A start this tab requested maps "stopped with a live pid" (the Host's
// "launch still booting" signal) to `starting`; a plain status read
// keeps the Host's phase, so a leftover process never locks the card.
@@ -232,6 +245,14 @@ export function useService(
// Otherwise fall back to the reported start error.
}
}
+ // The Host refused because the record describes a live process it
+ // cannot stop or verify. Neither Start nor Stop can act on it, so offer
+ // the record-only escape hatch.
+ if (
+ started.code === "unverified-record" ||
+ started.code === "previous-not-stopped"
+ )
+ setCanForget(true);
applyState({ phase: "error", error: startErrorText(t, started) });
return;
}
@@ -262,6 +283,7 @@ export function useService(
);
if (controller.signal.aborted) return;
if (status.phase === "running") {
+ setCanForget(false);
applyState({ phase: "running", data: status });
return;
}
@@ -328,6 +350,12 @@ export function useService(
);
if (endpointRef.current !== target) return;
setStopError(result.stopped ? "" : stopReasonText(t, result));
+ // A record the Host refuses to stop (unverifiable, or a process that
+ // survived the force-kill) cannot be cleared by either button: offer the
+ // record-only way out instead of leaving two actions that both fail.
+ if (result.stopped) setCanForget(false);
+ else if (result.code === "unverified" || result.code === "not-exited")
+ setCanForget(true);
refresh();
} catch (error) {
if (endpointRef.current !== target) return;
@@ -339,6 +367,32 @@ export function useService(
}
}, [applyState, endpoint, refresh, rpc, stopping, t]);
+ const forget = useCallback(async () => {
+ if (forgetting) return;
+ const target = endpoint;
+ setForgetting(true);
+ try {
+ const result = parseServiceForget(
+ await envCall(rpc, "serviceForget", { endpoint: target }),
+ );
+ if (endpointRef.current !== target) return;
+ if (result.forgotten) {
+ setCanForget(false);
+ setStopError("");
+ // Drop the stale pid immediately; the refresh confirms it.
+ applyState({ phase: "checking" });
+ refresh();
+ } else {
+ setStopError(stopReasonText(t, result));
+ }
+ } catch (error) {
+ if (endpointRef.current !== target) return;
+ applyState({ phase: "error", error: safeError(error) });
+ } finally {
+ if (endpointRef.current === target) setForgetting(false);
+ }
+ }, [applyState, endpoint, forgetting, refresh, rpc, t]);
+
const viewLog = useCallback(async () => {
try {
const value = parseServiceLog(
@@ -359,8 +413,11 @@ export function useService(
state,
stopping,
stopError,
+ canForget,
+ forgetting,
start,
stop,
+ forget,
refresh,
log,
viewLog,
@@ -452,14 +509,16 @@ export function ServiceSection({
current: boolean;
onAutoStart(checked: boolean): void;
}): JSX.Element {
- const { state, stopping, stopError, log } = control;
+ const { state, stopping, stopError, canForget, forgetting, log } = control;
const [confirming, setConfirming] = useState(false);
+ const [forgetConfirm, setForgetConfirm] = 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]);
+ setForgetConfirm(false);
+ }, [endpoint, state.phase, state.data?.pid, canForget]);
// 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
@@ -569,6 +628,9 @@ export function ServiceSection({
{stopError}