fix(dsh): verify service identity before touching a recorded pid

- require command-line evidence for owned service processes
- discard unprovable stale records instead of blocking startup
- identify and stop our own unhealthy conflict; serialize stop with start
- reject IPv6 loopback endpoints
- rotate oversized logs and fix tailing multibyte cuts
- add tests for env writes, client failures and real spawner
This commit is contained in:
LiangshouX 2026-10-28 23:42:02 +08:00
parent fddeb8d710
commit a91022a5bc
14 changed files with 693 additions and 93 deletions

View file

@ -70,7 +70,8 @@ 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.
- 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.
- When the plugin unloads, running jobs are stopped.
## Workspace `.env`
@ -117,18 +118,22 @@ processes it manages when the Host exits. The plugin owns a small service adapte
- The launch is recorded in `$DSH_HOME/reme-memory/service.json` (pid, endpoint, workspace, command, start time)
**after** the OS acknowledged the spawn, so a missing interpreter leaves no state behind.
- A service is reported as managed — and offered a **Stop** button — only when the record matches the endpoint on
screen, the pid is still alive, and its image is still a Python or `reme` executable. A record for port 2333
never claims a service on port 2444, and a recycled pid is not mistaken for this service. When the image cannot
be read, ownership fails closed: the process is shown as foreign rather than offered a Stop button.
screen, the pid is still alive, its image is still a Python or `reme` executable, **and** its command line still
carries the recorded `workspace_dir` and `service.port`. The command line is what makes a recycled pid safe: an
image name alone cannot prove which launch a pid belongs to. A record for port 2333 never claims a service on
port 2444. When the evidence cannot be read, ownership stays unproven: the process is shown as foreign, Stop
refuses to touch it, and a later Start drops the stale record instead of being blocked forever.
- 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 to `SIGKILL` if it ignores the graceful signal, and clears the record only after the pid is
gone. A service started elsewhere stays untouched and is only observed.
- **Start** is serialized: two requests cannot spawn two services, and a request that arrives while a launch is
still in flight is told so instead of silently pretending to start.
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.
- **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; otherwise it drops the record without touching the process.
- **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running.
Python probes and pip installs are bounded Host operations; the service is the only intentionally detached
process.
Python probes and pip installs are bounded Host operations; the service is the only process that must outlive DSH.
## Platform support

View file

@ -60,7 +60,7 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部。
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`。
- 插件卸载时,仍在运行的安装会被停止。
## Workspace `.env`
@ -97,15 +97,19 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把
- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间),
所以解释器缺失时不会留下残留状态。
- 只有记录与当前地址一致、pid 仍存活、且进程映像仍是 Python 或 `reme` 时,服务才被判定为“由本插件托管”并给出**停止**按钮。
2333 端口上的记录绝不会声称管理 2444 端口上的服务,被复用的 pid 也不会被误认。读不到进程映像时按失败关闭处理:
显示为外部进程,不给出停止按钮。
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,忽略优雅信号时升级为
`SIGKILL`,并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,只会被观察。
- **启动**是串行化的:两个请求不会拉起两个服务;启动仍在进行时到达的请求会被告知,而不是假装正在启动。
- 只有记录与当前地址一致、pid 仍存活、进程映像仍是 Python 或 `reme`,**且命令行仍包含记录的 `workspace_dir` 与
`service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行是防止 pid 复用的关键证据:只看映像名
无法证明 pid 属于哪次启动。2333 端口上的记录绝不会声称管理 2444 端口上的服务。证据读不到时按失败关闭处理:显示为
外部进程,停止操作不会碰它;之后点启动会丢弃这条过期记录,而不是永久卡住。
- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX:
`SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,
只会被观察。
- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有
归属被证明才会杀掉它;证明不了就只丢弃记录,不碰进程。
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。
Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一被刻意分离出去的进程。
Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要比 DSH 活得更久的进程。
## 平台支持

View file

@ -289,6 +289,12 @@ export function ServiceSection({
}): JSX.Element {
const { state, stopping, stopError, log } = control;
const [confirming, setConfirming] = useState(false);
// 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.
const canStop =
state.phase === "running" ||
(state.phase === "conflict" && state.data?.managed === true);
return (
<section className="reme-env-card">
@ -338,11 +344,13 @@ export function ServiceSection({
{t("recheck")}
</button>
</div>
{state.phase === "running" ? (
{state.phase === "running" || state.phase === "conflict" ? (
<p className="reme-settings-muted">
{state.data?.managed
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
: t("foreignNote")}
: state.phase === "running"
? t("foreignNote")
: null}
</p>
) : null}
{state.phase === "conflict" ? (
@ -359,7 +367,7 @@ export function ServiceSection({
</p>
) : null}
<div className="reme-env-cta">
{state.phase === "running" ? (
{canStop ? (
confirming ? (
<div className="reme-env-confirm">
<p>{t("stopConfirm")}</p>

View file

@ -32,7 +32,7 @@ function isLocalEndpoint(endpoint: string): boolean {
const url = new URL(endpoint);
return (
url.protocol === "http:" &&
["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname)
["127.0.0.1", "localhost"].includes(url.hostname)
);
} catch {
return false;

View file

@ -483,11 +483,15 @@ export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
const first = windows
? await io.run("where.exe", ["python"], { timeoutMs: 5_000 })
: await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 });
// A non-zero exit just means "no match"; a spawn/timeout error is a real
// diagnostic and must not disappear from the report.
if (first.error) errors.push(`python: ${safeError(first.error)}`);
let stdout = first.stdout;
if (!windows) {
const extra = await io.run("which", ["-a", "python"], {
timeoutMs: 5_000,
});
if (extra.error) errors.push(`python: ${safeError(extra.error)}`);
stdout = `${stdout}\n${extra.stdout}`;
}
for (const exe of parsePathList(stdout)) {

View file

@ -3,6 +3,7 @@ import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
import { join } from "node:path";
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
import { openAppendLog } from "./log.js";
import { isAbsolutePath } from "./paths.js";
export interface InstallChild {
@ -57,12 +58,17 @@ export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
fd = openSync(path, "r");
const size = fstatSync(fd).size;
const length = Math.min(size, maxBytes);
const start = size - length;
const buffer = Buffer.alloc(length);
readSync(fd, buffer, 0, length, Math.max(0, size - length));
const text = buffer
.toString("utf8")
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n");
readSync(fd, buffer, 0, length, start);
let text = buffer.toString("utf8");
if (start > 0) {
// The cut may be mid-line and even mid-character; drop that partial line
// so the tail never starts with a replacement character.
const firstBreak = text.indexOf("\n");
text = firstBreak >= 0 ? text.slice(firstBreak + 1) : text;
}
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
return text
.split("\n")
.filter((line) => line.trim().length > 0)
@ -87,7 +93,7 @@ export function createInstallSpawner(
): InstallSpawner {
return {
start({ exe, args, logPath }) {
const fd = openSync(logPath, "a");
const fd = openAppendLog(logPath);
let child: ReturnType<typeof spawn>;
try {
child = spawn(exe, args, {

View file

@ -0,0 +1,24 @@
import { mkdirSync, openSync, renameSync, statSync } from "node:fs";
import { dirname } from "node:path";
/** Rotate a log once it grows past this size; only one backup is kept. */
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
/**
* Open a log for appending, rotating an oversized one to `<path>.1` first.
* Install and service logs otherwise grow without bound because both are
* append-only and outlive many sessions.
*/
export function openAppendLog(
path: string,
maxBytes = LOG_ROTATE_BYTES,
): number {
mkdirSync(dirname(path), { recursive: true });
try {
if (statSync(path).size > maxBytes) renameSync(path, `${path}.1`);
} catch {
// A missing log has nothing to rotate; a locked one (Windows, while the
// service still writes it) is left alone rather than failing the launch.
}
return openSync(path, "a");
}

View file

@ -9,7 +9,11 @@ import { checkReme, MINIMUM_REME, remeVersionAtLeast } from "./reme.js";
import { createNodeRunner } from "./runner.js";
import {
createServiceIO,
processAlive,
readServiceState,
removeServiceState,
serviceEndpointKey,
serviceOwnership,
serviceStatus,
startService,
stopService,
@ -181,21 +185,30 @@ export class ReMeEnvManager {
async serviceStatus(endpoint: string, authToken = ""): Promise<ServiceState> {
const endpointValue = this.endpoint(endpoint);
const persisted = readServiceState(this.serviceStatePath);
// A malformed record must not turn the log path into a relative one.
let logDir = resolveWorkspaceDir("", homedir());
const recordedDir = persisted?.workspaceDir?.trim() ?? "";
if (recordedDir) {
try {
logDir = resolveWorkspaceDir(recordedDir, homedir());
} catch {
// Keep the default log directory.
}
}
const status = await serviceStatus(this.serviceIO, {
endpoint: endpointValue,
statePath: this.serviceStatePath,
authToken,
logPath: join(
persisted?.workspaceDir ?? resolveWorkspaceDir("", homedir()),
"service.log",
),
logPath: join(logDir, "service.log"),
});
if (this.starting?.endpoint === endpointValue) {
// Running is the happy end; stopped without a live pid means the launch
// died before it ever listened, so a retry must not be blocked by the
// grace period. A stopped status *with* a pid is a service still booting.
// Running and conflict both mean the port answered: the launch is no
// longer "in flight". Stopped without a live pid means it died before it
// ever listened, so a retry must not be blocked either. A stopped status
// *with* a pid is a service still booting.
if (
status.phase === "running" ||
status.phase === "conflict" ||
(status.phase === "stopped" && status.pid === undefined)
)
this.starting = undefined;
@ -224,14 +237,20 @@ export class ReMeEnvManager {
return run;
}
/** Stop the plugin-managed service; a foreign service stays untouched. */
async serviceStop(endpoint: string): Promise<ServiceStopResult> {
const result = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: this.endpoint(endpoint),
});
if (result.stopped) this.starting = undefined;
return result;
/**
* Stop the plugin-managed service; a foreign service stays untouched. Stop
* joins the start queue, so a request that arrives while a launch is still
* being recorded waits for it and then stops that launch instead of
* reporting "nothing started".
*/
serviceStop(endpoint: string): Promise<ServiceStopResult> {
const endpointValue = this.endpoint(endpoint);
const run = this.startQueue.then(() => this.stopLocked(endpointValue));
this.startQueue = run.then(
() => undefined,
() => undefined,
);
return run;
}
/** Tail of the workspace service log. */
@ -288,6 +307,16 @@ export class ReMeEnvManager {
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
}
private async stopLocked(endpointValue: string): Promise<ServiceStopResult> {
const result = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: endpointValue,
});
if (result.stopped && this.starting?.endpoint === endpointValue)
this.starting = undefined;
return result;
}
private async startLocked(
endpoint: string,
pythonPath: string,
@ -301,13 +330,14 @@ export class ReMeEnvManager {
const starting = this.starting;
if (starting && Date.now() - starting.at < START_GRACE_MS) {
if (starting.endpoint === endpointValue)
// A genuine launch is in flight. Observing it is not an error: the
// client keeps polling just as the first request does.
return {
started: false,
alreadyRunning: true,
command: "",
logPath,
startedAt: now,
error: "The service is already starting for this endpoint",
};
// The service record is per install, not per endpoint; spawning a second
// endpoint's service now would overwrite the first one's record and
@ -332,22 +362,23 @@ export class ReMeEnvManager {
startedAt: now,
...(current.version ? { version: current.version } : {}),
};
if (current.phase === "conflict")
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: "Another process is already listening on this endpoint",
};
// A previous launch that never became healthy may still be alive. Stop it
// before spawning a replacement, so it cannot be orphaned when the state
// record is overwritten.
if (current.pid !== undefined) {
if (current.phase === "conflict") {
// The port is answered by something that is not a healthy service. A
// provable launch of ours (still booting or rejecting our token) is
// stopped and replaced; anything else is left alone.
if (!current.managed)
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: "Another process is already listening on this endpoint",
};
const previous = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: endpointValue,
ownership: "owned",
});
if (!previous.stopped)
return {
@ -361,6 +392,64 @@ export class ReMeEnvManager {
}`,
};
}
// A record for a *different* endpoint that is still alive would be
// overwritten by the new launch, leaving that endpoint unable to stop it.
// Only a provably owned process blocks the new start; an unprovable record
// is stale and is discarded, just like the same-endpoint case below.
const recorded = readServiceState(this.serviceStatePath);
if (
recorded &&
recorded.pid !== undefined &&
serviceEndpointKey(recorded.endpoint) !==
serviceEndpointKey(endpointValue) &&
processAlive(this.serviceIO, recorded.pid)
) {
const other = await serviceOwnership(
this.serviceIO,
recorded,
recorded.endpoint,
);
if (other === "owned")
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
};
removeServiceState(this.serviceStatePath);
}
// A previous launch that never became healthy may still be alive. Stop it
// when ownership is proven; when it cannot be proven, drop the stale record
// without killing anything so a recycled pid is never touched.
if (current.pid !== undefined) {
const ownership = await serviceOwnership(
this.serviceIO,
recorded,
endpointValue,
);
if (ownership === "owned") {
const previous = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: endpointValue,
ownership,
});
if (!previous.stopped)
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: `A previous launch could not be stopped: ${
previous.reason ?? "unknown reason"
}`,
};
} else {
removeServiceState(this.serviceStatePath);
}
}
const path = pythonPath.trim();
if (!path)
return {

View file

@ -3,7 +3,6 @@ import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
@ -14,6 +13,7 @@ import { basename, dirname } from "node:path";
import { safeError } from "../../endpoint.js";
import type { ServiceStartResult, ServiceState } from "../types.js";
import { openAppendLog } from "./log.js";
/**
* The local ReMe service is a *detached* process: it outlives DSH, which is the
@ -65,6 +65,8 @@ export interface ServiceIO {
probePid(pid: number): void;
/** OS image name of a pid, or "" when it cannot be read. */
processImage(pid: number): Promise<string>;
/** Full command line of a pid, or "" when it cannot be read. */
processCommandLine?(pid: number): Promise<string>;
exists(path: string): boolean;
platform: string;
}
@ -109,9 +111,11 @@ export function parseLocalEndpoint(endpoint: string): {
throw new TypeError(
"Local service management requires an http:// endpoint",
);
if (!["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname))
// The service is launched with `service.host=127.0.0.1`; accepting an IPv6
// loopback would spawn a process that can never answer that endpoint.
if (!["127.0.0.1", "localhost"].includes(url.hostname))
throw new TypeError(
"Local service management requires a loopback endpoint",
"Local service management requires an IPv4 loopback endpoint",
);
const port = Number(url.port || "80");
if (!Number.isInteger(port) || port < 1 || port > 65535)
@ -202,7 +206,8 @@ export function writeServiceState(path: string, state: PersistedService): void {
}
}
function clearServiceState(path: string): void {
/** Drop the service record without touching the recorded process. */
export function removeServiceState(path: string): void {
try {
rmSync(path, { force: true });
} catch {
@ -210,6 +215,8 @@ function clearServiceState(path: string): void {
}
}
const clearServiceState = removeServiceState;
function alive(io: ServiceIO, pid?: number): boolean {
if (!isValidPid(pid)) return false;
try {
@ -220,6 +227,11 @@ function alive(io: ServiceIO, pid?: number): boolean {
}
}
/** Whether a pid is alive, rejecting pids this plugin never records. */
export function processAlive(io: ServiceIO, pid?: number): boolean {
return alive(io, pid);
}
/**
* Whether an OS image name is a process that could be running the ReMe
* service: an exact Python-family name or `reme`, never a prefix match (which
@ -231,26 +243,89 @@ export function isServiceImage(image: string): boolean {
}
/**
* Whether the recorded process is the service this plugin started for the
* endpoint on screen. The saved endpoint must match the requested one, the pid
* must still be alive, and its image must still be a Python (or `reme`)
* executable, which is what a recycled pid would rarely be.
* Ownership of the recorded process, as far as this plugin can prove it.
*
* This guards a destructive operation, so it fails *closed*: an unreadable
* image is not proof of ownership.
* - `owned`: endpoint, pid, image and command line all match the launch this
* plugin recorded.
* - `foreign`: at least one piece of evidence contradicts the record.
* - `unknown`: the process exists but its image or command line could not be
* read (localized `tasklist`, `ps` failure, missing permissions).
*
* Stop is destructive, so it only accepts `owned`; starting can discard an
* `unknown` record (without killing it) instead of being blocked forever.
*/
export type ServiceOwnership = "owned" | "foreign" | "unknown";
/** Compare paths and ports with separators and case normalized. */
function normalizeCommandText(value: string): string {
return value.replaceAll("\\\\", "\\").replaceAll("/", "\\").toLowerCase();
}
/**
* The recorded launch always passes `workspace_dir=<dir>` and
* `service.port=<port>` as argv entries (also inside the Python wrapper's
* `-c` script), so their presence is strong evidence that this pid is the
* process the record describes — not merely another Python that reused the pid.
*/
function commandMatches(command: string, persisted: PersistedService): boolean {
let port: number;
try {
port = parseLocalEndpoint(persisted.endpoint).port;
} catch {
return false;
}
const workspace = persisted.workspaceDir.trim();
if (!workspace) return false;
const text = normalizeCommandText(command);
const marker = `workspace_dir=${normalizeCommandText(workspace)}`;
const index = text.indexOf(marker);
if (index < 0) return false;
// Require a boundary so `C:\a` does not match another service using `C:\ab`.
const next = text[index + marker.length] ?? "";
if (next && !/[\s"',\]]/.test(next)) return false;
return new RegExp(`service\\.port=${port}(?!\\d)`).test(text);
}
export async function serviceOwnership(
io: ServiceIO,
persisted: PersistedService | undefined,
endpoint: string,
): Promise<ServiceOwnership> {
if (!persisted || !isValidPid(persisted.pid)) return "foreign";
if (serviceEndpointKey(persisted.endpoint) !== serviceEndpointKey(endpoint))
return "foreign";
if (!alive(io, persisted.pid)) return "foreign";
let image = "";
try {
image = await io.processImage(persisted.pid);
} catch {
image = "";
}
if (!image.trim()) return "unknown";
if (!isServiceImage(image)) return "foreign";
let command = "";
try {
command = (await io.processCommandLine?.(persisted.pid)) ?? "";
} catch {
command = "";
}
if (!command.trim()) return "unknown";
return commandMatches(command, persisted) ? "owned" : "foreign";
}
/**
* Whether the recorded process is provably the service this plugin started
* for the endpoint on screen.
*
* This guards a destructive operation, so it fails *closed*: unreadable
* evidence is not proof of ownership.
*/
export async function ownsService(
io: ServiceIO,
persisted: PersistedService | undefined,
endpoint: string,
): Promise<boolean> {
if (!persisted || !isValidPid(persisted.pid)) return false;
if (serviceEndpointKey(persisted.endpoint) !== serviceEndpointKey(endpoint))
return false;
if (!alive(io, persisted.pid)) return false;
const image = await io.processImage(persisted.pid);
if (!image.trim()) return false;
return isServiceImage(image);
return (await serviceOwnership(io, persisted, endpoint)) === "owned";
}
/** Running / conflict / stopped, with ownership of the running process. */
@ -276,11 +351,15 @@ export async function serviceStatus(
// A record whose process is gone must not be reported, or a later start
// would keep seeing a stale pid. Remove it while nobody can be relying on it.
if (persisted && !recordAlive) clearServiceState(options.statePath);
// Ownership is expensive (image plus command line). During boot the service
// is polled every second while `stopped`, where it is not needed, so it is
// computed lazily only for the phases that report it.
const ownership = async (): Promise<ServiceOwnership> =>
recordForEndpoint && recordAlive
? serviceOwnership(io, persisted, options.endpoint)
: "foreign";
if (health.ok) {
const managed =
recordForEndpoint &&
recordAlive &&
(await ownsService(io, persisted, options.endpoint));
const managed = (await ownership()) === "owned";
return {
phase: "running",
managed,
@ -300,19 +379,26 @@ export async function serviceStatus(
} catch {
// A remote or https endpoint is health-checked only; it has no local port.
}
if (portOpen)
if (portOpen) {
// Our own service can occupy the port without being healthy (still
// booting, or a rejected token); identify it so the tab can stop it.
const managed = (await ownership()) === "owned";
return {
phase: "conflict",
managed: false,
managed,
endpoint: options.endpoint,
...(managed && persisted?.pid !== undefined
? { pid: persisted.pid }
: {}),
logPath: options.logPath,
};
}
return {
phase: "stopped",
managed: false,
endpoint: options.endpoint,
// A live record for this endpoint means a launch that never became
// healthy; the manager stops it before spawning a replacement.
// healthy; the manager stops or discards it before spawning a replacement.
...(recordForEndpoint && recordAlive && persisted?.pid !== undefined
? { pid: persisted.pid }
: {}),
@ -463,6 +549,8 @@ export async function stopService(
timeoutMs?: number;
forceTimeoutMs?: number;
pollMs?: number;
/** Precomputed ownership from the caller; avoids a second process probe. */
ownership?: ServiceOwnership;
},
): Promise<ServiceStopResult> {
const persisted = readServiceState(options.statePath);
@ -470,7 +558,16 @@ export async function stopService(
if (persisted) clearServiceState(options.statePath);
return { stopped: false, reason: "No service started by this plugin" };
}
if (!(await ownsService(io, persisted, options.endpoint)))
const ownership =
options.ownership ??
(await serviceOwnership(io, persisted, options.endpoint));
if (ownership === "unknown")
return {
stopped: false,
reason:
"The recorded process identity could not be verified; refusing to stop it",
};
if (ownership !== "owned")
return {
stopped: false,
reason: "The recorded service does not belong to this endpoint",
@ -509,7 +606,7 @@ export async function stopService(
return {
stopped: false,
pid,
reason: "The service did not exit after SIGKILL",
reason: "The service did not exit after the force-kill",
};
}
@ -572,7 +669,7 @@ export function createServiceIO(): ServiceIO {
});
},
async spawnService({ exe, args, cwd, logPath, env }) {
const fd = openSync(logPath, "a");
const fd = openAppendLog(logPath);
let child: ReturnType<typeof spawn>;
try {
child = spawn(exe, args, {
@ -657,6 +754,37 @@ export function createServiceIO(): ServiceIO {
);
});
},
processCommandLine(pid) {
return new Promise((resolve) => {
if (!isValidPid(pid)) {
resolve("");
return;
}
if (process.platform === "win32") {
// wmic is absent from recent Windows builds, so ask CIM. The encoding
// prefix keeps non-ASCII workspace paths intact through the pipe.
execFile(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
`[Console]::OutputEncoding=[Text.Encoding]::UTF8; (Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`,
],
{ windowsHide: true, timeout: 5000 },
(error, stdout) =>
resolve(error ? "" : String(stdout ?? "").trim()),
);
return;
}
execFile(
"ps",
["-p", String(pid), "-o", "args="],
{ timeout: 3000 },
(error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()),
);
});
},
exists: existsSync,
platform: process.platform,
};

View file

@ -1,11 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ReMeEnvManager } from "../dist/environment/host/manager.js";
import { writeServiceState } from "../dist/environment/host/service.js";
import {
readServiceState,
writeServiceState,
} from "../dist/environment/host/service.js";
/** A service adapter that records launches instead of starting processes. */
function fakeServiceIO() {
@ -24,6 +33,7 @@ function fakeServiceIO() {
killTree() {},
probePid() {},
processImage: async () => "python.exe",
processCommandLine: async () => "",
exists: () => true,
platform: "win32",
},
@ -137,6 +147,8 @@ test("stops a previous launch that never became healthy before replacing it", as
calls.killTree.push(pid);
running = false;
};
io.processCommandLine = async () =>
`reme start workspace_dir=${directory} service.port=2334`;
const result = await manager.serviceStart(
"http://127.0.0.1:2334",
@ -148,6 +160,96 @@ test("stops a previous launch that never became healthy before replacing it", as
assert.equal(calls.spawn.length, 1);
});
test("an unprovable previous record is discarded without killing it", 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 process exists, but its command line cannot be read, so ownership is
// unproven: never kill it, just drop the stale record and start.
io.processCommandLine = async () => "";
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, []);
assert.equal(calls.spawn.length, 1);
assert.equal(readServiceState(statePath)?.pid, 5001);
});
test("refuses to overwrite a live record owned by another endpoint", async (t) => {
const { manager, calls, directory, io } = managerIn(t);
manager.serviceStatus = async (endpoint) => ({
phase: "stopped",
managed: false,
endpoint,
logPath: join(directory, "service.log"),
});
writeServiceState(join(directory, "reme-memory", "service.json"), {
pid: 6000,
endpoint: "http://127.0.0.1:2333",
workspaceDir: directory,
pythonPath: "D:\\env\\python.exe",
command: "reme start",
startedAt: "2026-10-27T00:00:00.000Z",
});
io.processCommandLine = async () =>
`reme start workspace_dir=${directory} service.port=2333`;
const result = await manager.serviceStart(
"http://127.0.0.1:2334",
"D:\\env\\python.exe",
join(directory, "workspace"),
);
assert.equal(result.started, false);
assert.match(result.error, /still running/);
assert.equal(calls.spawn.length, 0);
});
test("stop waits for an in-flight start and then stops that launch", async (t) => {
const { manager, calls, directory, io } = managerIn(t);
let running = true;
io.killTree = (pid) => {
calls.killTree.push(pid);
running = false;
};
io.probePid = () => {
if (!running) throw new Error("ESRCH");
};
io.processCommandLine = async () =>
`reme start workspace_dir=${join(
directory,
"workspace",
)} service.port=2334`;
const start = manager.serviceStart(
"http://127.0.0.1:2334",
"D:\\env\\python.exe",
join(directory, "workspace"),
);
// Arrives while the start is still being recorded, so it must join the queue
// rather than answering "No service started by this plugin".
const stop = manager.serviceStop("http://127.0.0.1:2334");
const [started, stopped] = await Promise.all([start, stop]);
assert.equal(started.started, true);
assert.equal(stopped.stopped, true);
assert.deepEqual(calls.killTree, [5001]);
});
test("probes health from the Host and rejects a non-http endpoint", async (t) => {
const { manager } = managerIn(t);
const health = await manager.health("http://127.0.0.1:2334");
@ -182,3 +284,20 @@ test("validates the merged .env, not just the submitted values", async (t) => {
);
assert.match(readFileSync(envPath, "utf8"), /^LLM_API_KEY=sk-keep$/m);
});
test("writes .env atomically with a backup and owner-only mode", (t) => {
const { manager, directory } = managerIn(t);
const envPath = join(directory, ".env");
writeFileSync(
envPath,
"LLM_API_KEY=sk-old\nEMBEDDING_API_KEY=EMPTY\nLLM_MODEL_NAME=old\n",
);
manager.writeEnv(directory, { LLM_MODEL_NAME: "new" });
assert.match(readFileSync(envPath, "utf8"), /^LLM_MODEL_NAME=new$/m);
const backup = readFileSync(`${envPath}.bak`, "utf8");
assert.match(backup, /^LLM_MODEL_NAME=old$/m);
assert.match(backup, /^LLM_API_KEY=sk-old$/m);
if (process.platform !== "win32")
assert.equal(statSync(envPath).mode & 0o777, 0o600);
});

View file

@ -181,8 +181,9 @@ function fakeSpawner() {
onExit(callback) {
child.exit = callback;
},
kill() {
kill(force = false) {
child.killed = true;
child.force = force;
},
};
children.push(child);

View file

@ -1,6 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
closeSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInstallSpawner } from "../dist/environment/host/install.js";
import { openAppendLog } from "../dist/environment/host/log.js";
import { createNodeRunner } from "../dist/environment/host/runner.js";
test("a timeout settles with an explicit error instead of hanging", async () => {
@ -28,3 +40,37 @@ test("a child that exits on SIGTERM is still reported as a timeout", async () =>
);
assert.match(result.error ?? "", /timed out after 200 ms/);
});
test("the real install spawner starts and stops a process", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-spawner-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const spawner = createInstallSpawner({ PATH: process.env.PATH });
const logPath = join(directory, "install.log");
const child = spawner.start({
exe: process.execPath,
args: ["-e", "setInterval(() => {}, 1000)"],
logPath,
});
assert.ok(child.pid !== undefined && child.pid > 1);
const exited = new Promise((resolve) => child.onExit(resolve));
child.kill();
const outcome = await Promise.race([
exited,
new Promise((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
]);
assert.notEqual(outcome, "timeout", "the install process must actually exit");
assert.match(readFileSync(logPath, "utf8"), /^$/);
});
test("an oversized log is rotated before the next append", (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-log-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const path = join(directory, "install.log");
writeFileSync(path, "x".repeat(200));
const fd = openAppendLog(path, 100);
closeSync(fd);
assert.equal(readFileSync(`${path}.1`, "utf8").length, 200);
assert.equal(statSync(path).size, 0);
});

View file

@ -11,6 +11,7 @@ import {
ownsService,
parseLocalEndpoint,
readServiceState,
serviceOwnership,
serviceStatus,
startService,
stopService,
@ -34,6 +35,7 @@ function fakeIO(overrides = {}) {
},
probePid() {},
processImage: async () => "python.exe",
processCommandLine: async () => "",
exists: () => true,
platform: "win32",
...overrides,
@ -41,6 +43,12 @@ function fakeIO(overrides = {}) {
return { io, calls };
}
/** The command line a launch for `statePath` would actually have. */
function commandFor(statePath, port = 2334) {
const state = readServiceState(statePath);
return `reme start workspace_dir=${state.workspaceDir} service.port=${port}`;
}
function tempState(t, prefix) {
const directory = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(directory, { recursive: true, force: true }));
@ -77,6 +85,9 @@ test("accepts only loopback HTTP endpoints and validates the port", () => {
() => parseLocalEndpoint("https://127.0.0.1:2333"),
/http:\/\//,
);
// The launch binds service.host=127.0.0.1, so an IPv6 loopback endpoint
// would start a service that can never answer it.
assert.throws(() => parseLocalEndpoint("http://[::1]:2333"), /IPv4/);
});
test("builds reme start with the workspace cwd contract", () => {
@ -140,6 +151,7 @@ test("derives running, conflict and stopped phases", async (t) => {
assert.equal(status.version, "0.4.0.9");
record(statePath);
runningIo.io.processCommandLine = async () => commandFor(statePath);
status = await serviceStatus(runningIo.io, base);
assert.equal(status.phase, "running");
assert.equal(status.managed, true);
@ -176,6 +188,7 @@ test("a loopback alias still identifies the service this plugin started", async
record(statePath, { endpoint: "http://localhost:2334/" });
const { io } = fakeIO({
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
processCommandLine: async () => commandFor(statePath),
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
@ -186,6 +199,23 @@ test("a loopback alias still identifies the service this plugin started", async
assert.equal(status.pid, 777);
});
test("a conflict still identifies a provably owned service", async (t) => {
const { directory, statePath } = tempState(t, "reme-conflict-");
record(statePath);
const { io } = fakeIO({
portOpen: async () => true,
processCommandLine: async () => commandFor(statePath),
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "conflict");
assert.equal(status.managed, true);
assert.equal(status.pid, 777);
});
test("a recycled pid that is no longer the service is not managed", async (t) => {
const { statePath } = tempState(t, "reme-pid-");
record(statePath);
@ -217,6 +247,40 @@ test("a recycled pid that is no longer the service is not managed", async (t) =>
`image ${JSON.stringify(image)} must not be owned`,
);
}
// A Python image whose command line describes another launch is foreign,
// not ours; an unreadable command line is unproven, not ownership.
const { io: otherLaunch } = fakeIO({
processCommandLine: async () => "python -m http.server 2334",
});
assert.equal(
await serviceOwnership(otherLaunch, readServiceState(statePath), ENDPOINT),
"foreign",
);
const { io: unproven } = fakeIO();
assert.equal(
await serviceOwnership(unproven, readServiceState(statePath), ENDPOINT),
"unknown",
);
assert.equal(
await ownsService(unproven, readServiceState(statePath), ENDPOINT),
false,
);
// Matching is boundary-aware: a longer workspace path or port is another
// launch, not this one.
const state = readServiceState(statePath);
for (const command of [
`reme start workspace_dir=${state.workspaceDir}2 service.port=2334`,
`reme start workspace_dir=${state.workspaceDir} service.port=23340`,
]) {
const { io: longer } = fakeIO({
processCommandLine: async () => command,
});
assert.equal(
await serviceOwnership(longer, state, ENDPOINT),
"foreign",
command,
);
}
});
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
@ -286,7 +350,9 @@ test("records nothing when the spawn fails", async (t) => {
test("stops the managed pid and clears the state file after it exits", async (t) => {
const { directory, statePath } = tempState(t, "reme-stop-");
record(statePath, { pid: 999, workspaceDir: directory });
const { io, calls } = fakeIO();
const { io, calls } = fakeIO({
processCommandLine: async () => commandFor(statePath),
});
let running = true;
io.probePid = () => {
if (!running) throw new Error("ESRCH");
@ -313,7 +379,9 @@ test("stops the managed pid and clears the state file after it exits", async (t)
test("escalates to SIGKILL and keeps the record when the service never exits", async (t) => {
const { directory, statePath } = tempState(t, "reme-stubborn-");
record(statePath, { pid: 999, workspaceDir: directory });
const { io, calls } = fakeIO();
const { io, calls } = fakeIO({
processCommandLine: async () => commandFor(statePath),
});
const stopped = await stopService(io, {
statePath,
endpoint: ENDPOINT,
@ -340,6 +408,17 @@ test("refuses to stop a service recorded for another endpoint", async (t) => {
assert.ok(readServiceState(statePath), "the foreign record is left in place");
});
test("refuses to stop a process whose identity cannot be verified", async (t) => {
const { statePath } = tempState(t, "reme-unknown-");
record(statePath);
const { io, calls } = fakeIO(); // image readable, command line not
const stopped = await stopService(io, { statePath, endpoint: ENDPOINT });
assert.equal(stopped.stopped, false);
assert.match(stopped.reason, /could not be verified/);
assert.deepEqual(calls.killTree, []);
assert.equal(readServiceState(statePath)?.pid, 777);
});
/* ------------------------------------------------------------------ */
/* Real IO: the pieces the fake adapter above cannot vouch for. */
/* ------------------------------------------------------------------ */
@ -402,6 +481,7 @@ test("real IO spawns, identifies and stops a detached process", async (t) => {
assert.ok(pid !== undefined && pid > 1);
assert.doesNotThrow(() => io.probePid(pid));
assert.doesNotMatch(await io.processImage(pid), /^$/);
assert.match(await io.processCommandLine(pid), /node/i);
io.killTree(pid);
const deadline = Date.now() + 5_000;

View file

@ -116,8 +116,9 @@ function texts(node, found = []) {
return found;
}
async function mount(overrides = {}) {
async function mount(overrides = {}, options = {}) {
const calls = { mutate: [], rpc: [] };
const responses = { ...RESPONSES, ...(options.responses ?? {}) };
const listeners = new Set();
// A new object per change, the way the Host-backed form publishes a snapshot.
let snapshot = {
@ -142,13 +143,13 @@ async function mount(overrides = {}) {
getSnapshot: () => snapshot,
mutate: async (ops, revision) => {
calls.mutate.push({ ops, revision });
return true;
return options.mutate ? options.mutate() : true;
},
};
const rpc = {
call: async (_path, method, payload) => {
calls.rpc.push({ method, input: payload?.args?.input });
return { ok: true, value: RESPONSES[method] ?? null };
return { ok: true, value: responses[method] ?? null };
},
};
@ -171,7 +172,13 @@ async function mount(overrides = {}) {
};
for (const listener of listeners) listener();
},
restore: () => listeners.clear(),
restore: () => {
// Unmount so the effect cleanup paths (aborts, timers) actually run.
act(() => {
renderer.unmount();
});
listeners.clear();
},
};
}
@ -352,3 +359,82 @@ test("probes the service from the Host, never from the browser", async (t) => {
"a local endpoint is covered by the service probe already",
);
});
test("a remote endpoint never runs the local setup steps", async (t) => {
const { calls, restore } = await mount(
{ endpoint: "http://10.0.0.5:2333" },
{
responses: {
"remeEnv/health": {
endpoint: "http://10.0.0.5:2333",
ok: false,
version: "",
error: "unreachable",
checkedAt: new Date().toISOString(),
},
},
},
);
t.after(restore);
const methods = calls.rpc.map((call) => call.method);
assert.ok(
methods.includes("remeEnv/health"),
"a remote endpoint is only health-checked",
);
for (const local of [
"remeEnv/python",
"remeEnv/checkReme",
"remeEnv/readEnv",
"remeEnv/serviceStatus",
])
assert.equal(methods.includes(local), false, `${local} must not run`);
});
test("shows why Stop left the service alone", 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 recorded process identity could not be verified",
},
},
},
);
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));
});
assert.match(texts(renderer.toJSON()).join(" "), /could not be verified/);
});
test("surfaces a rejected settings write", async (t) => {
const { renderer, restore } = await mount({}, { mutate: async () => false });
t.after(restore);
const checkbox = renderer.root.findAll(
(node) => node.type === "input" && node.props.type === "checkbox",
)[0];
assert.ok(checkbox);
await act(async () => {
checkbox.props.onChange({ target: { checked: true } });
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
assert.match(texts(renderer.toJSON()).join(" "), /设置未保存/);
});