fix(dsh): let an unverifiable service record be forgotten

This commit is contained in:
LiangshouX 2026-10-29 01:54:29 +08:00
parent cf043bd098
commit 1db40e8fff
15 changed files with 444 additions and 35 deletions

View file

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

View file

@ -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 已被其它程序复用,这条记录可证明属于外部:点**启动**会丢弃记录并拉起新服务。若完全无法验证身份,
点**忘记这条记录**——它只删除本插件的记录,不会碰进程。
**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从
这里启动。

View file

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

View file

@ -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: "服务在健康检查中报告了错误。",

View file

@ -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({
<input
type="radio"
name="reme-python-interpreter"
checked={false}
disabled={!env.ok || saving !== "" || !writable}
checked={env.exe === selectedPath}
disabled={
env.exe === selectedPath ||
!env.ok ||
saving !== "" ||
!writable
}
onChange={() => {
selection.select(env);
setListOpen(false);
}}
/>
<strong>{`Python ${env.version || "?"}`}</strong>
<em>{originLabel(t, env.origin)}</em>
<em>
{env.exe === selectedPath
? t("currentInterpreter")
: originLabel(t, env.origin)}
</em>
<InterpreterBadges
t={t}
env={env}

View file

@ -6,6 +6,7 @@ import type {
LlmTestResult,
PythonDetection,
RemeCheck,
ServiceForgetResult,
ServiceLog,
ServiceStartResult,
ServiceState,
@ -26,6 +27,7 @@ export type EnvMethod =
| "serviceStatus"
| "serviceStart"
| "serviceStop"
| "serviceForget"
| "serviceLog";
/**
@ -258,6 +260,16 @@ export function parseServiceStop(value: unknown): ServiceStopResult {
return value as unknown as ServiceStopResult;
}
export function parseServiceForget(value: unknown): ServiceForgetResult {
const what = "service forget";
const body = record(value, what);
booleanField(body, "forgotten", what);
optionalStringField(body, "reason", what);
optionalStringField(body, "code", what);
optionalPidField(body, "pid", what);
return value as unknown as ServiceForgetResult;
}
export function parseServiceLog(value: unknown): ServiceLog {
const what = "service log";
const body = record(value, what);

View file

@ -11,6 +11,7 @@ import { StatusBadge } from "./presentation.js";
import {
envCall,
parseHealth,
parseServiceForget,
parseServiceLog,
parseServiceStart,
parseServiceState,
@ -78,8 +79,12 @@ export interface ServiceControl {
stopping: boolean;
/** Why the last Stop request left the service alone, if it did. */
stopError: string;
/** The record cannot be cleared by Start/Stop; it can only be forgotten. */
canForget: boolean;
forgetting: boolean;
start(): Promise<void>;
stop(): Promise<void>;
forget(): Promise<void>;
refresh(): void;
log: ServiceLogView;
viewLog(): Promise<void>;
@ -106,6 +111,8 @@ export function useService(
const [state, setState] = useState<ServiceViewState>({ 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<ServiceLogView>({
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}
</p>
) : null}
{current && canForget ? (
<p className="reme-settings-muted">{t("forgetHint")}</p>
) : null}
<div className="reme-env-cta">
{canStop ? (
confirming ? (
@ -620,6 +682,40 @@ export function ServiceSection({
{t("startService")}
</button>
) : null}
{current && canForget ? (
forgetConfirm ? (
<div className="reme-env-confirm">
<p>{t("forgetConfirm")}</p>
<button
type="button"
className="reme-settings-discard"
disabled={forgetting}
onClick={() => {
setForgetConfirm(false);
void control.forget();
}}
>
{t("forgetRecord")}
</button>
<button
type="button"
className="reme-settings-button"
onClick={() => setForgetConfirm(false)}
>
{t("cancel")}
</button>
</div>
) : (
<button
type="button"
className="reme-settings-discard"
disabled={forgetting}
onClick={() => setForgetConfirm(true)}
>
{t("forgetRecord")}
</button>
)
) : null}
<button
type="button"
className="reme-settings-button"

View file

@ -8,6 +8,7 @@ import type {
LlmTestResult,
PythonDetection,
RemeCheck,
ServiceForgetResult,
ServiceLog,
ServiceStartResult,
ServiceState,
@ -177,6 +178,18 @@ export class ReMeEnvGateway extends TypertRemoteService {
return this.ctx.remeEnv.serviceStop(input?.endpoint ?? "");
}
/**
* Drop the service record without signalling the process. This is the way
* out of a live record whose identity cannot be verified, where Start and
* Stop both refuse to act.
*/
@Remote("serviceForget")
async serviceForget(
input: { endpoint?: string } | undefined,
): Promise<ServiceForgetResult> {
return this.ctx.remeEnv.serviceForget(input?.endpoint ?? "");
}
/** Tail of the workspace service log for start-failure diagnosis. */
@Remote("serviceLog")
async serviceLog(

View file

@ -9,6 +9,7 @@ import { checkReme, MINIMUM_REME, remeVersionAtLeast } from "./reme.js";
import { createNodeRunner } from "./runner.js";
import {
createServiceIO,
forgetService,
processAlive,
readServiceState,
removeServiceState,
@ -36,6 +37,7 @@ import type {
LlmTestResult,
PythonDetection,
RemeCheck,
ServiceForgetResult,
ServiceLog,
ServiceStartResult,
ServiceState,
@ -255,6 +257,30 @@ export class ReMeEnvManager {
return run;
}
/**
* Drop this endpoint's service record without touching the process. It is
* the escape hatch for a live process whose identity cannot be verified
* (Stop and Start both refuse to act on it). Joins the start queue so a
* launch that is still being recorded completes first.
*/
serviceForget(endpoint: string): Promise<ServiceForgetResult> {
const endpointValue = this.endpoint(endpoint);
const run = this.startQueue.then(() => {
const result = forgetService({
statePath: this.serviceStatePath,
endpoint: endpointValue,
});
if (result.forgotten && this.starting?.endpoint === endpointValue)
this.starting = undefined;
return result;
});
this.startQueue = run.then(
() => undefined,
() => undefined,
);
return run;
}
/** Tail of the workspace service log. */
serviceLog(workspaceDir: string): ServiceLog {
const dir = resolveWorkspaceDir(workspaceDir, homedir());

View file

@ -15,6 +15,7 @@ import { safeError } from "../../endpoint.js";
import { serviceEndpointKey } from "../endpoint-key.js";
import type {
HealthErrorCode,
ServiceForgetResult,
ServiceStartResult,
ServiceState,
ServiceStopResult,
@ -623,6 +624,41 @@ export async function stopService(
};
}
/**
* Drop the service record without signalling any process.
*
* Stop and Start both fail closed when ownership cannot be proven. With a live
* but unverifiable process (for example one started by an elevated DSH) that
* leaves the tab with no working action at all, so forgetting the record is the
* explicit way out: the plugin stops managing the process and the user confirms
* what to do with it themselves.
*/
export function forgetService(options: {
statePath: string;
endpoint: string;
}): ServiceForgetResult {
const persisted = readServiceState(options.statePath);
if (!persisted || !isValidPid(persisted.pid)) {
if (persisted) clearServiceState(options.statePath);
return {
forgotten: false,
reason: "No service started by this plugin",
code: "no-record",
};
}
if (
serviceEndpointKey(persisted.endpoint) !==
serviceEndpointKey(options.endpoint)
)
return {
forgotten: false,
reason: "The recorded service does not belong to this endpoint",
code: "foreign",
};
clearServiceState(options.statePath);
return { forgotten: true, pid: persisted.pid };
}
/** Node-backed IO: fetch health, probe the port, spawn detached, tree-kill. */
export function createServiceIO(): ServiceIO {
return {

View file

@ -193,3 +193,15 @@ export interface ServiceStopResult {
/** Stable reason the client localizes; `reason` stays for diagnostics. */
code?: ServiceStopReasonCode;
}
/**
* Outcome of dropping a service record without touching the process. This is
* the escape hatch for a live process whose identity cannot be verified: Stop
* and Start both refuse to act on it, so the record can only be forgotten.
*/
export interface ServiceForgetResult {
forgotten: boolean;
pid?: number;
reason?: string;
code?: ServiceStopReasonCode;
}

View file

@ -30,6 +30,11 @@ function stubManager() {
error: authToken ? "auth token forwarded" : "",
checkedAt: "",
}),
serviceForget: async (endpoint) => ({
forgotten: false,
reason: endpoint,
code: "no-record",
}),
};
}
@ -120,3 +125,25 @@ test("health keeps the single-input convention and forwards the token", async ()
assert.equal(result.endpoint, "http://127.0.0.1:2334");
assert.equal(result.error, "auth token forwarded");
});
test("serviceForget keeps the single-input convention and forwards the endpoint", async () => {
const { root } = await mounted();
const receiver = root.reflect.get("remeEnvGateway", false);
const source = Function.prototype.toString.call(
Object.getPrototypeOf(receiver).serviceForget,
);
const open = source.indexOf("(");
const close = source.indexOf(")", open + 1);
const parameters = source
.slice(open + 1, close)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
assert.deepEqual(parameters, ["input"]);
const result = await receiver.serviceForget({
endpoint: "http://127.0.0.1:2334",
});
assert.equal(result.forgotten, false);
assert.equal(result.reason, "http://127.0.0.1:2334");
});

View file

@ -189,12 +189,77 @@ test("an unprovable previous record blocks the start instead of orphaning it", a
);
assert.equal(result.started, false);
assert.equal(result.code, "unverified-record");
assert.equal(result.pid, 6000, "the client needs the pid in the message");
assert.deepEqual(calls.killTree, []);
assert.equal(calls.spawn.length, 0);
// Once the evidence is readable again, the same record can be stopped.
assert.equal(readServiceState(statePath)?.pid, 6000);
});
test("a provably foreign previous record is discarded and replaced", async (t) => {
const { manager, calls, directory, io } = managerIn(t);
manager.serviceStatus = async (endpoint) => ({
phase: "stopped",
managed: false,
endpoint,
pid: 6000,
logPath: join(directory, "service.log"),
});
const statePath = join(directory, "reme-memory", "service.json");
writeServiceState(statePath, {
pid: 6000,
endpoint: "http://127.0.0.1:2334",
workspaceDir: directory,
pythonPath: "D:\\env\\python.exe",
command: "reme start",
startedAt: "2026-10-27T00:00:00.000Z",
});
// The live pid belongs to something else now, so the record is provably
// stale: the start drops it without killing the process and spawns fresh.
io.processCommandLine = async () => "python -m http.server 2334";
const result = await manager.serviceStart(
"http://127.0.0.1:2334",
"D:\\env\\python.exe",
join(directory, "workspace"),
);
assert.equal(result.started, true);
assert.deepEqual(calls.killTree, [], "a foreign process is never signalled");
assert.equal(calls.spawn.length, 1);
assert.equal(readServiceState(statePath)?.pid, 5001);
});
test("forgetting a record removes it without signalling the process", async (t) => {
const { manager, calls, directory } = managerIn(t);
const statePath = join(directory, "reme-memory", "service.json");
writeServiceState(statePath, {
pid: 6000,
endpoint: "http://127.0.0.1:2334",
workspaceDir: directory,
pythonPath: "D:\\env\\python.exe",
command: "reme start",
startedAt: "2026-10-27T00:00:00.000Z",
});
// Another endpoint cannot drop this record.
const foreign = await manager.serviceForget("http://127.0.0.1:2333");
assert.deepEqual(foreign, {
forgotten: false,
reason: "The recorded service does not belong to this endpoint",
code: "foreign",
});
assert.equal(readServiceState(statePath)?.pid, 6000);
const forgotten = await manager.serviceForget("http://127.0.0.1:2334");
assert.deepEqual(forgotten, { forgotten: true, pid: 6000 });
assert.equal(readServiceState(statePath), undefined);
// Forgetting only touches the record: the process is never signalled.
assert.deepEqual(calls.killTree, []);
const again = await manager.serviceForget("http://127.0.0.1:2334");
assert.equal(again.forgotten, false);
assert.equal(again.code, "no-record");
});
test("refuses to overwrite a live record owned by another endpoint", async (t) => {
const { manager, calls, directory, io } = managerIn(t);
manager.serviceStatus = async (endpoint) => ({

View file

@ -477,19 +477,18 @@ async function listening(handler, t) {
/**
* 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.
* Only EBUSY/ENOTEMPTY are transient; anything else (and a persistent lock)
* fails loudly instead of leaking a temporary directory.
*/
async function removeDirectory(path) {
for (let attempt = 0; attempt < 20; attempt += 1) {
for (let attempt = 0; ; 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;
if ((code !== "EBUSY" && code !== "ENOTEMPTY") || attempt >= 19)
throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}

View file

@ -246,11 +246,11 @@ test("keeps the interpreter list collapsed until asked, then paginates", async (
renderer.root
.findAll(
(node) =>
node.type === "button" && textOf(node.props.children).includes("7"),
node.type === "button" && textOf(node.props.children).includes("8"),
)[0]
.props.onClick();
});
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 7);
assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 8);
});
test("marks the suggested interpreter and explains why", async (t) => {
@ -272,6 +272,13 @@ test("marks the suggested interpreter and explains why", async (t) => {
const radios = renderer.root.findAll(
(node) => node.type === "input" && node.props.type === "radio",
);
// The current interpreter stays in the group, checked and disabled, so
// assistive technology can announce the selection.
assert.equal(radios.filter((node) => node.props.checked === true).length, 1);
assert.equal(
radios.find((node) => node.props.checked === true).props.disabled,
true,
);
const target = radios.find((node) => node.props.disabled === false);
assert.ok(target);
await act(async () => {
@ -442,6 +449,47 @@ test("shows why Stop left the service alone", async (t) => {
assert.ok(
texts(renderer.toJSON()).join(" ").includes(envZh.stopUnknownIdentity),
);
// Stop can never succeed for this record, so the record-only way out is
// offered next to the explanation.
assert.ok(buttonByText(renderer, envZh.forgetRecord));
});
test("a process that survived the force-kill still offers forgetting the record", async (t) => {
const { renderer, restore } = await mount(
{},
{
responses: {
"remeEnv/serviceStatus": {
phase: "running",
managed: true,
pid: 98820,
version: "0.4.0.9",
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
},
"remeEnv/serviceStop": {
stopped: false,
reason: "The service did not exit after the force-kill",
code: "not-exited",
},
},
},
);
t.after(restore);
await act(async () => {
buttonByText(renderer, envZh.stopService).props.onClick();
});
await act(async () => {
buttonByText(renderer, envZh.stopService).props.onClick();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const copy = texts(renderer.toJSON()).join(" ");
assert.ok(copy.includes(envZh.stopNotExited));
// Start would refuse with `previous-not-stopped`, so the record must be
// forgettable instead of leaving no exit at all.
assert.ok(buttonByText(renderer, envZh.forgetRecord));
});
test("surfaces a rejected settings write", async (t) => {
@ -1226,7 +1274,7 @@ test("an unverified record reports the pid it refused to touch", async (t) => {
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
};
const { renderer, restore } = await mount(
const { renderer, calls, restore } = await mount(
{},
{
responses: {
@ -1241,6 +1289,7 @@ test("an unverified record reports the pid it refused to touch", async (t) => {
logPath: stopped.logPath,
startedAt: new Date().toISOString(),
},
"remeEnv/serviceForget": { forgotten: true, pid: 6000 },
},
},
);
@ -1253,6 +1302,27 @@ test("an unverified record reports the pid it refused to touch", async (t) => {
});
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);
// Both Start and Stop are guaranteed to fail here, so the record-only way
// out must be offered instead of leaving the card with no working action.
assert.ok(copy.includes(envZh.forgetHint));
assert.ok(buttonByText(renderer, envZh.forgetRecord));
await act(async () => {
buttonByText(renderer, envZh.forgetRecord).props.onClick();
});
assert.ok(texts(renderer.toJSON()).join(" ").includes(envZh.forgetConfirm));
await act(async () => {
buttonByText(renderer, envZh.forgetRecord).props.onClick();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
assert.ok(
calls.rpc.some((call) => call.method === "remeEnv/serviceForget"),
"forgetting reaches the Host",
);
const after = texts(renderer.toJSON()).join(" ");
assert.equal(after.includes(envZh.forgetHint), false);
assert.equal(after.includes(envZh.forgetRecord), false);
});