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}

) : null} + {current && canForget ? ( +

{t("forgetHint")}

+ ) : null}
{canStop ? ( confirming ? ( @@ -620,6 +682,40 @@ export function ServiceSection({ {t("startService")} ) : null} + {current && canForget ? ( + forgetConfirm ? ( +
+

{t("forgetConfirm")}

+ + +
+ ) : ( + + ) + ) : null}