fix(dsh): finish the environment review follow-ups

- match escaped wrapper command lines when proving service ownership
- reset aborted LLM probes and consume stopped/conflict start signals
- abort start polling on endpoint switch and guard stale service state
- force --force-reinstall for Reinstall; localize start/stop reasons
- validate rendered RPC fields, prune install logs and polish UI states
This commit is contained in:
LiangshouX 2026-10-29 00:21:19 +08:00
parent a91022a5bc
commit 603ea41fba
19 changed files with 736 additions and 75 deletions

View file

@ -59,9 +59,10 @@ The check asks the selected interpreter three questions and reports them as metr
- `[core]` extras: whether `import agentscope` works. Without it, `auto_memory` and `auto_dream` are unavailable.
- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time.
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with an upgrade and a package
index (PyPI, Tsinghua, Aliyun). Large wheels are downloaded on first use; expect 5–20 minutes depending on the
network.
**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI,
Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a
present-but-broken install as "already satisfied". Large wheels are downloaded on first use; expect 5–20 minutes
depending on the network.
The installation is a Host-owned job:
@ -71,7 +72,7 @@ The installation is a Host-owned job:
- **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually
exited.
- The log is written to `$DSH_HOME/reme-memory/install-<job>.log` and its tail is shown in place; install and
service logs are rotated to `.log.1` once they pass 5 MB.
service logs are rotated to `.log.1` once they pass 5 MB, and only the newest 20 install logs are kept.
- When the plugin unloads, running jobs are stopped.
## Workspace `.env`

View file

@ -52,15 +52,17 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列
- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加升级与软件源(PyPI、清华、阿里云)。首次使用需要
下载较大的依赖,视网络情况通常 5–20 分钟。
**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加
`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的
依赖,视网络情况通常 5–20 分钟。
安装是 Host 侧的任务:
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`。
- 日志写入 `$DSH_HOME/reme-memory/install-<job>.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`,
并且目录只保留最近 20 个安装日志。
- 插件卸载时,仍在运行的安装会被停止。
## Workspace `.env`

View file

@ -31,7 +31,11 @@ export interface DependencyState {
/** Wall clock tick while an installation runs, for the elapsed label. */
now: number;
recheck(): void;
start(upgrade: boolean, indexUrl?: string): Promise<void>;
start(
upgrade: boolean,
indexUrl?: string,
forceReinstall?: boolean,
): Promise<void>;
cancel(): Promise<void>;
dismiss(): void;
}
@ -177,7 +181,7 @@ export function useDependency(
}, []);
const start = useCallback(
async (upgrade: boolean, indexUrl?: string) => {
async (upgrade: boolean, indexUrl?: string, forceReinstall?: boolean) => {
if (!pythonPath || install.phase === "running") return;
setInstall({ phase: "idle" });
try {
@ -186,6 +190,7 @@ export function useDependency(
pythonPath,
upgrade,
...(indexUrl ? { indexUrl } : {}),
...(forceReinstall ? { forceReinstall } : {}),
}),
);
setInstall(
@ -256,7 +261,7 @@ export function DependencySection({
: checkData.versionOk && checkData.hasCore
? { tone: "ok" as const, text: t("depReady") }
: checkData.versionOk
? { tone: "progress" as const, text: t("coreMissing") }
? { tone: "progress" as const, text: t("coreMissingBadge") }
: { tone: "progress" as const, text: t("depUpgrade") };
return (
@ -302,7 +307,9 @@ export function DependencySection({
</p>
) : null}
{checkData?.error ? (
<p className="reme-settings-error">{checkData.error}</p>
<p className="reme-settings-error">
{`${t("checkRemeFailed")}: ${checkData.error}`}
</p>
) : null}
{checkData ? (
<div className="reme-env-metrics">
@ -386,11 +393,16 @@ export function DependencySection({
className="reme-settings-save"
onClick={() => {
setConfirming(false);
// Fresh install: no upgrade. Outdated: upgrade. Current
// but suspect (Reinstall): force pip to reinstall.
const reinstall =
checkData.installed && checkData.versionOk;
// The mirror choice reaches pip as `--index-url`; without
// it the selector would be decorative.
void dependency.start(
!checkData.versionOk,
checkData.installed && !checkData.versionOk,
mirror || undefined,
reinstall,
);
}}
>

View file

@ -42,6 +42,7 @@ export const envEn = {
upgradeRecommended: "upgrade recommended",
coreOk: "Available",
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
coreMissingBadge: "core missing",
cliFallback: "python wrapper fallback",
installButton: "Install reme-ai[core]",
upgradeButton: "Upgrade reme-ai",
@ -97,17 +98,41 @@ export const envEn = {
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
serviceBadgeStarting: "Starting…",
serviceBadgeConflict: "Port occupied",
serviceBadgeUnhealthy: "Not healthy",
serviceBadgeStopped: "Stopped",
serviceRunning: "Running",
startService: "Start service",
stopService: "Stop service",
startTimeout: "The service did not become healthy within 120 seconds",
startFailedStopped:
"The service exited before it became healthy. Open the log below.",
startErrorOtherEndpoint:
"A service start for another endpoint is still in progress.",
startErrorConflict: "Another process is already listening on this endpoint.",
startErrorOtherEndpointRunning:
"A service for another endpoint is still running; stop it there first.",
startErrorPreviousNotStopped:
"The previous service could not be stopped; check the log.",
startErrorNoPython: "Select a Python interpreter first.",
startErrorNotInstalled:
"reme-ai is not installed in the selected interpreter.",
startErrorVersionTooOld:
"The installed reme-ai is too old; upgrade it first.",
startErrorFailed: "The service could not be started; check the log.",
stopNoRecord: "No service was started by this plugin.",
stopForeign: "The recorded service does not belong to this endpoint.",
stopUnknownIdentity:
"The recorded process could not be verified, so it was left running.",
stopNotExited:
"The service did not exit; its record was kept so you can retry.",
stopConfirm:
"Stop the ReMe service? Automatic memory in flight may need a retry.",
foreignNote:
"This service was started outside DSH; its state is shown but it is not managed here.",
conflictNote:
"Another program occupies this port. Change the service URL port in Memory settings.",
conflictManagedNote:
"This plugin's service holds the port but is not reporting healthy. Stop it to recover.",
viewLogs: "View log",
logEmpty: "The service log is empty.",
autoStartLabel: "Start the service automatically with DSH",
@ -192,6 +217,7 @@ export const envZh: typeof envEn = {
upgradeRecommended: "建议升级",
coreOk: "可用",
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
coreMissingBadge: "缺少 core",
cliFallback: "回退到 python 包装",
installButton: "安装 reme-ai[core]",
upgradeButton: "升级 reme-ai",
@ -244,14 +270,31 @@ export const envZh: typeof envEn = {
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
serviceBadgeStarting: "启动中…",
serviceBadgeConflict: "端口被占用",
serviceBadgeUnhealthy: "未就绪",
serviceBadgeStopped: "已停止",
serviceRunning: "运行中",
startService: "启动服务",
stopService: "停止服务",
startTimeout: "服务在 120 秒内未就绪",
startFailedStopped: "服务在就绪前已退出,请查看下方日志。",
startErrorOtherEndpoint: "另一个服务地址的启动仍在进行中。",
startErrorConflict: "该端口已被其他程序占用。",
startErrorOtherEndpointRunning:
"另一个服务地址的服务仍在运行,请先到那个地址停止它。",
startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
startErrorNoPython: "请先选择一个 Python 解释器。",
startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
startErrorFailed: "服务未能启动,请查看日志。",
stopNoRecord: "本插件没有启动过该服务。",
stopForeign: "记录中的服务不属于当前服务地址。",
stopUnknownIdentity: "无法验证记录中的进程身份,已保持其运行。",
stopNotExited: "服务未退出;已保留记录,可稍后重试。",
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
conflictManagedNote:
"本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
viewLogs: "查看日志",
logEmpty: "服务日志为空。",
autoStartLabel: "随 DSH 启动时自动拉起服务",

View file

@ -67,18 +67,28 @@ export function usePython(
setDetection({ phase: "ready", data: parseDetection(value) });
} catch (error) {
if (!controller.signal.aborted)
setDetection({ phase: "error", error: safeError(error) });
setDetection((current) => ({
phase: "error",
error: safeError(error),
...(current.data ? { data: current.data } : {}),
}));
}
})();
return () => controller.abort();
}, [rpc, tick, enabled]);
const detected = detection.phase === "ready" ? detection.data : undefined;
// A refresh keeps the last detection data, so the selected interpreter (and
// the install/progress panels keyed off it) does not flash away while the
// new probe is in flight.
const detected = detection.data;
const suggested = detected?.suggested ?? "";
const selectedPath = pythonPath || suggested;
const refresh = useCallback(() => {
setDetection({ phase: "loading" });
setDetection((current) => ({
phase: "loading",
...(current.data ? { data: current.data } : {}),
}));
setTick((current) => current + 1);
}, []);

View file

@ -77,6 +77,14 @@ function booleanField(
return value;
}
function optionalStringField(
body: Record<string, unknown>,
key: string,
what: string,
): void {
if (body[key] !== undefined && typeof body[key] !== "string") fail(what);
}
function arrayField(
body: Record<string, unknown>,
key: string,
@ -105,7 +113,29 @@ export function parseDetection(value: unknown): PythonDetection {
stringField(env, "exe", what);
stringField(env, "version", what);
booleanField(env, "ok", what);
record(env.origin, what);
const origin = record(env.origin, what);
const kind = stringField(origin, "kind", what);
if (
![
"conda",
"python.org",
"windows-store",
"venv",
"system",
"unknown",
].includes(kind)
)
fail(what);
if (kind === "conda") {
stringField(origin, "envName", what);
stringField(origin, "root", what);
}
if (kind === "venv") stringField(origin, "baseExe", what);
if (env.reme !== undefined) {
const reme = record(env.reme, what);
booleanField(reme, "installed", what);
stringField(reme, "version", what);
}
}
return value as PythonDetection;
}
@ -118,6 +148,7 @@ export function parseRemeCheck(value: unknown): RemeCheck {
booleanField(body, "hasCore", what);
stringField(body, "version", what);
stringField(body, "cliPath", what);
stringField(body, "error", what);
return value as unknown as RemeCheck;
}
@ -130,6 +161,8 @@ export function parseInstallStatus(value: unknown): InstallStatus | null {
stringField(body, "pythonPath", what);
stringField(body, "startedAt", what);
stringField(body, "logPath", what);
stringField(body, "logTail", what);
if (body.cancelling !== undefined) booleanField(body, "cancelling", what);
const phase = stringField(body, "phase", what);
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
fail(what);
@ -190,6 +223,8 @@ export function parseServiceStart(value: unknown): ServiceStartResult {
stringField(body, "command", what);
stringField(body, "logPath", what);
stringField(body, "startedAt", what);
optionalStringField(body, "error", what);
optionalStringField(body, "code", what);
return value as unknown as ServiceStartResult;
}
@ -197,12 +232,15 @@ export interface ServiceStopView {
stopped: boolean;
pid?: number;
reason?: string;
code?: string;
}
export function parseServiceStop(value: unknown): ServiceStopView {
const what = "service stop";
const body = record(value, what);
booleanField(body, "stopped", what);
optionalStringField(body, "reason", what);
optionalStringField(body, "code", what);
return value as ServiceStopView;
}

View file

@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import type { ServiceState } from "../types.js";
import type { EnvTranslator } from "./locales.js";
import { fill } from "./locales.js";
import type { ServiceStartResult, ServiceState } from "../types.js";
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
import { StatusBadge } from "./presentation.js";
import {
envCall,
@ -21,6 +20,38 @@ import {
*/
const START_DEADLINE_MS = 120_000;
/** Host start errors carry a stable code; older/unknown codes fall back. */
const START_ERROR_KEYS: Record<string, EnvKey> = {
"other-endpoint-starting": "startErrorOtherEndpoint",
conflict: "startErrorConflict",
"other-endpoint-running": "startErrorOtherEndpointRunning",
"previous-not-stopped": "startErrorPreviousNotStopped",
"no-python": "startErrorNoPython",
"not-installed": "startErrorNotInstalled",
"version-too-old": "startErrorVersionTooOld",
"start-failed": "startErrorFailed",
};
function startErrorText(t: EnvTranslator, result: ServiceStartResult): string {
const key = result.code ? START_ERROR_KEYS[result.code] : undefined;
return key ? t(key) : result.error || t("serviceError");
}
const STOP_REASON_KEYS: Record<string, EnvKey> = {
"no-record": "stopNoRecord",
foreign: "stopForeign",
unverified: "stopUnknownIdentity",
"not-exited": "stopNotExited",
};
function stopReasonText(
t: EnvTranslator,
result: { code?: string; reason?: string },
): string {
const key = result.code ? STOP_REASON_KEYS[result.code] : undefined;
return key ? t(key) : result.reason || t("serviceError");
}
export interface ServiceViewState {
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
data?: ServiceState;
@ -75,9 +106,15 @@ export function useService(
const startController = useRef<AbortController | undefined>(undefined);
// A new endpoint has no known phase yet; keeping the previous endpoint's
// "running + old pid/version" on screen would mislabel the new one.
// "running + old pid/version" on screen would mislabel the new one. A start
// poll for the old endpoint must stop before it can paint that old data.
useEffect(() => {
startController.current?.abort();
// Resetting the view when the endpoint prop changes is intentional; the
// hook has no key, so this is the reset path.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState({ phase: "checking" });
setStopError("");
}, [endpoint]);
useEffect(() => () => startController.current?.abort(), []);
@ -96,18 +133,19 @@ export function useService(
),
);
if (controller.signal.aborted) return;
setState((current) =>
current.phase === "starting"
? current
: { phase: next.phase, data: next },
);
// Recheck must not be swallowed while a start is in flight: the
// response belongs to the endpoint on screen. `stopped` with a live pid
// is the Host's "launch still booting" signal.
setState({
phase:
next.phase === "stopped" && next.pid !== undefined
? "starting"
: next.phase,
data: next,
});
} catch (error) {
if (controller.signal.aborted) return;
setState((current) =>
current.phase === "starting"
? current
: { phase: "error", error: safeError(error) },
);
setState({ phase: "error", error: safeError(error) });
}
})();
return () => controller.abort();
@ -134,7 +172,7 @@ export function useService(
);
if (controller.signal.aborted) return;
if (started.error) {
setState({ phase: "error", error: started.error });
setState({ phase: "error", error: startErrorText(t, started) });
return;
}
const deadline = Date.now() + START_DEADLINE_MS;
@ -154,10 +192,23 @@ export function useService(
controller.signal,
),
);
if (controller.signal.aborted) return;
if (status.phase === "running") {
setState({ phase: "running", data: status });
return;
}
if (status.phase === "conflict") {
// Someone (possibly our own booting service) owns the port.
setState({ phase: "conflict", data: status });
return;
}
if (status.phase === "stopped" && status.pid === undefined) {
// The Host already dropped the start marker: the launch died before
// it ever listened, so waiting out the 120 s deadline is wrong.
setState({ phase: "error", error: t("startFailedStopped") });
return;
}
// stopped with a live pid: the process is still booting.
} catch {
// Keep polling: a starting service answers once it is listening.
}
@ -175,7 +226,7 @@ export function useService(
const result = parseServiceStop(
await envCall(rpc, "serviceStop", { endpoint }),
);
setStopError(result.stopped ? "" : result.reason || t("serviceError"));
setStopError(result.stopped ? "" : stopReasonText(t, result));
refresh();
} catch (error) {
setState({ phase: "error", error: safeError(error) });
@ -276,6 +327,7 @@ export function ServiceSection({
remeInstalled,
autoStart,
writable,
current,
onAutoStart,
}: {
t: EnvTranslator;
@ -285,6 +337,8 @@ export function ServiceSection({
remeInstalled: boolean;
autoStart: boolean;
writable: boolean;
/** False while the state on screen belongs to another endpoint. */
current: boolean;
onAutoStart(checked: boolean): void;
}): JSX.Element {
const { state, stopping, stopError, log } = control;
@ -293,8 +347,9 @@ export function ServiceSection({
// 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);
current &&
(state.phase === "running" ||
(state.phase === "conflict" && state.data?.managed === true));
return (
<section className="reme-env-card">
@ -307,10 +362,14 @@ export function ServiceSection({
<p>{t("serviceHint")}</p>
</div>
<aside>
{state.phase === "checking" || state.phase === "starting" ? (
{!current ||
state.phase === "checking" ||
state.phase === "starting" ? (
<StatusBadge tone="progress">
{t(
state.phase === "starting"
!current
? "checking"
: state.phase === "starting"
? "serviceBadgeStarting"
: "checking",
)}
@ -323,7 +382,11 @@ export function ServiceSection({
</StatusBadge>
) : state.phase === "conflict" ? (
<StatusBadge tone="progress">
{t("serviceBadgeConflict")}
{t(
state.data?.managed
? "serviceBadgeUnhealthy"
: "serviceBadgeConflict",
)}
</StatusBadge>
) : (
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
@ -344,7 +407,8 @@ export function ServiceSection({
{t("recheck")}
</button>
</div>
{state.phase === "running" || state.phase === "conflict" ? (
{current &&
(state.phase === "running" || state.phase === "conflict") ? (
<p className="reme-settings-muted">
{state.data?.managed
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
@ -353,8 +417,10 @@ export function ServiceSection({
: null}
</p>
) : null}
{state.phase === "conflict" ? (
<p className="reme-settings-muted">{t("conflictNote")}</p>
{current && state.phase === "conflict" ? (
<p className="reme-settings-muted">
{state.data?.managed ? t("conflictManagedNote") : t("conflictNote")}
</p>
) : null}
{state.phase === "error" && state.error ? (
<p className="reme-settings-error">
@ -405,7 +471,11 @@ export function ServiceSection({
type="button"
className="reme-settings-save"
disabled={
state.phase === "starting" || !remeInstalled || !pythonPath
!current ||
state.phase === "starting" ||
state.phase === "conflict" ||
!remeInstalled ||
!pythonPath
}
onClick={() => void control.start()}
>

View file

@ -47,9 +47,13 @@ function endpointKey(endpoint: string): string {
try {
const url = new URL(endpoint);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
return `${
url.protocol
}//${url.hostname.toLowerCase()}:${port}${url.pathname.replace(
// Match the Host's loopback alias rule so `localhost` and `127.0.0.1`
// answers compare equal.
const host =
url.hostname.toLowerCase() === "localhost"
? "127.0.0.1"
: url.hostname.toLowerCase();
return `${url.protocol}//${host}:${port}${url.pathname.replace(
/\/+$/,
"",
)}`;
@ -133,8 +137,12 @@ export function ReMeEnvironmentTab({
const authToken = value?.authToken ?? "";
const [heroTick, setHeroTick] = useState(0);
// Settings writes are invisible to the sections otherwise: a rejected
// revision or a transport error would silently roll the control back.
const [settingsError, setSettingsError] = useState("");
// revision or a transport error would silently roll the control back. The
// message carries its endpoint so a change of URL drops it without an effect.
const [settingsError, setSettingsError] = useState<{
endpoint: string;
message: string;
}>({ endpoint: "", message: "" });
const writeSetting = useCallback(
async (field: string, next: unknown): Promise<boolean> => {
@ -145,18 +153,20 @@ export function ReMeEnvironmentTab({
snapshot.revision,
);
if (applied === false) {
setSettingsError(t("settingsWriteFailed"));
setSettingsError({ endpoint, message: t("settingsWriteFailed") });
return false;
}
setSettingsError("");
setSettingsError({ endpoint, message: "" });
return true;
} catch (error) {
setSettingsError(safeError(error));
setSettingsError({ endpoint, message: safeError(error) });
return false;
}
},
[scope, snapshot.revision, snapshot.writable, t],
[endpoint, scope, snapshot.revision, snapshot.writable, t],
);
const settingsErrorText =
settingsError.endpoint === endpoint ? settingsError.message : "";
const python = usePython(rpc, {
pythonPath: value?.pythonPath ?? "",
@ -198,6 +208,7 @@ export function ReMeEnvironmentTab({
// The Host names the endpoint its answer belongs to; comparing that with the
// settings endpoint is what makes a stale answer fail the `current` test.
const serviceEndpoint = service.state.data?.endpoint ?? endpoint;
const serviceCurrent = endpointKey(serviceEndpoint) === endpointKey(endpoint);
const connection: ConnectionState = local
? service.state.phase === "running"
? {
@ -222,8 +233,10 @@ export function ReMeEnvironmentTab({
const ready =
current &&
connection.phase === "ok" &&
dependency.checkData?.installed === true &&
dependency.checkData.versionOk;
(!local ||
(dependency.checkData?.installed === true &&
dependency.checkData.versionOk &&
dependency.checkData.hasCore));
const scannedAt = python.detection.data?.scannedAt
? Date.parse(python.detection.data.scannedAt)
: Number.NaN;
@ -259,8 +272,12 @@ export function ReMeEnvironmentTab({
</div>
<div className="reme-env-hero-chips">
<StatusBadge tone={healthTone}>{healthText}</StatusBadge>
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
{local ? (
<>
<StatusBadge tone={pythonTone}>{pythonText}</StatusBadge>
<StatusBadge tone={remeTone}>{remeText}</StatusBadge>
</>
) : null}
</div>
<div className="reme-env-hero-actions">
<button
@ -294,9 +311,9 @@ export function ReMeEnvironmentTab({
{connection.error}
</p>
) : null}
{settingsError ? (
{settingsErrorText ? (
<p className="reme-settings-error reme-env-hero-error" role="alert">
{settingsError}
{settingsErrorText}
</p>
) : null}
{!local ? (
@ -329,6 +346,7 @@ export function ReMeEnvironmentTab({
remeInstalled={dependency.checkData?.installed === true}
autoStart={value?.autoStart === true}
writable={snapshot.writable}
current={serviceCurrent}
onAutoStart={(checked) => {
void writeSetting("autoStart", checked);
}}

View file

@ -298,6 +298,10 @@ export function useWorkspace(
error: safeError(error),
},
});
} finally {
// An aborted probe never settles into a result; without this the
// button would stay disabled on "Testing…" until the tab remounts.
if (controller.signal.aborted) setLlmTest({ phase: "idle" });
}
},
[llmTest.phase, rpc, workspaceDir],

View file

@ -61,13 +61,19 @@ export class ReMeEnvGateway extends TypertRemoteService {
@Remote("install")
async install(
input:
| { pythonPath?: string; upgrade?: boolean; indexUrl?: string }
| {
pythonPath?: string;
upgrade?: boolean;
indexUrl?: string;
forceReinstall?: boolean;
}
| undefined,
): Promise<InstallStatus> {
return this.ctx.remeEnv.install({
pythonPath: input?.pythonPath ?? "",
upgrade: input?.upgrade === true,
indexUrl: typeof input?.indexUrl === "string" ? input.indexUrl : "",
forceReinstall: input?.forceReinstall === true,
});
}

View file

@ -3,7 +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 { openAppendLog, pruneInstallLogs } from "./log.js";
import { isAbsolutePath } from "./paths.js";
export interface InstallChild {
@ -31,6 +31,9 @@ export function pipArgs(request: InstallRequest): string[] {
"--no-input",
];
if (request.upgrade) args.push("-U");
// `pip install reme-ai[core]` reports "already satisfied" for a broken but
// present install, so an explicit Reinstall must force it.
if (request.forceReinstall) args.push("--force-reinstall");
const indexUrl = (request.indexUrl ?? "").trim();
if (indexUrl) {
if (!/^https?:\/\/\S+$/i.test(indexUrl))
@ -66,7 +69,10 @@ export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
// 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 =
firstBreak >= 0
? text.slice(firstBreak + 1)
: text.replace(/^\uFFFD+/, "");
}
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
return text
@ -206,6 +212,7 @@ export class InstallJobs {
36,
)}-${(this.sequence += 1).toString(36)}`;
mkdirSync(this.options.logDir, { recursive: true });
pruneInstallLogs(this.options.logDir);
const logPath = join(this.options.logDir, `${id}.log`);
const child = this.options.spawner.start({
exe: pythonPath,

View file

@ -1,8 +1,17 @@
import { mkdirSync, openSync, renameSync, statSync } from "node:fs";
import { dirname } from "node:path";
import {
mkdirSync,
openSync,
readdirSync,
renameSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join } 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;
/** Number of per-job install logs kept on disk. */
export const INSTALL_LOG_KEEP = 20;
/**
* Open a log for appending, rotating an oversized one to `<path>.1` first.
@ -22,3 +31,32 @@ export function openAppendLog(
}
return openSync(path, "a");
}
/**
* Keep only the newest `keep` per-job logs. Each install writes a new file, so
* size-based rotation alone never bounds the directory.
*/
export function pruneInstallLogs(
directory: string,
keep = INSTALL_LOG_KEEP,
): void {
try {
const entries = readdirSync(directory).filter(
(name) => name.startsWith("install-") && name.endsWith(".log"),
);
if (entries.length <= keep) return;
const byAge = entries
.map((name) => {
try {
return { name, mtime: statSync(join(directory, name)).mtimeMs };
} catch {
return { name, mtime: 0 };
}
})
.sort((left, right) => right.mtime - left.mtime);
for (const old of byAge.slice(keep))
rmSync(join(directory, old.name), { force: true });
} catch {
// Pruning is housekeeping; a missing directory is not an error.
}
}

View file

@ -349,6 +349,7 @@ export class ReMeEnvManager {
logPath,
startedAt: now,
error: `A service start for ${starting.endpoint} is still in progress`,
code: "other-endpoint-starting",
};
}
this.starting = undefined;
@ -374,6 +375,7 @@ export class ReMeEnvManager {
logPath,
startedAt: now,
error: "Another process is already listening on this endpoint",
code: "conflict",
};
const previous = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
@ -390,6 +392,7 @@ export class ReMeEnvManager {
error: `A previous launch could not be stopped: ${
previous.reason ?? "unknown reason"
}`,
code: "previous-not-stopped",
};
}
// A record for a *different* endpoint that is still alive would be
@ -417,6 +420,7 @@ export class ReMeEnvManager {
logPath,
startedAt: now,
error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`,
code: "other-endpoint-running",
};
removeServiceState(this.serviceStatePath);
}
@ -445,6 +449,7 @@ export class ReMeEnvManager {
error: `A previous launch could not be stopped: ${
previous.reason ?? "unknown reason"
}`,
code: "previous-not-stopped",
};
} else {
removeServiceState(this.serviceStatePath);
@ -459,6 +464,7 @@ export class ReMeEnvManager {
logPath,
startedAt: now,
error: "Select a Python interpreter first",
code: "no-python",
};
const check = await this.checkReme(path);
if (!check.installed)
@ -469,6 +475,7 @@ export class ReMeEnvManager {
logPath,
startedAt: now,
error: "reme-ai is not installed in the selected interpreter",
code: "not-installed",
};
if (!check.versionOk)
return {
@ -480,6 +487,7 @@ export class ReMeEnvManager {
error: `reme-ai ${
check.version || "(unknown version)"
} is older than ${MINIMUM_REME}; upgrade it first`,
code: "version-too-old",
};
// Set before the spawn so a second request arriving while the service is
// booting observes the first launch instead of spawning another one. It is
@ -508,6 +516,7 @@ export class ReMeEnvManager {
logPath,
startedAt: now,
error: `Could not start the ReMe service: ${safeError(error)}`,
code: "start-failed",
};
}
}

View file

@ -12,8 +12,13 @@ import { createConnection } from "node:net";
import { basename, dirname } from "node:path";
import { safeError } from "../../endpoint.js";
import type { ServiceStartResult, ServiceState } from "../types.js";
import type {
ServiceStartResult,
ServiceState,
ServiceStopReasonCode,
} from "../types.js";
import { openAppendLog } from "./log.js";
import { isWindowsPath } from "./paths.js";
/**
* The local ReMe service is a *detached* process: it outlives DSH, which is the
@ -45,6 +50,8 @@ export interface ServiceStopResult {
pid?: number;
/** Why an unmanaged or missing service was left alone. */
reason?: string;
/** Stable reason the client localizes; `reason` stays for diagnostics. */
code?: ServiceStopReasonCode;
}
export interface ServiceIO {
@ -256,9 +263,17 @@ export function isServiceImage(image: string): boolean {
*/
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();
/**
* Compare paths and ports with separators normalized. Windows command lines
* escape quotes as `\"`, so that sequence is folded back before matching; case
* is only folded for Windows paths, because POSIX paths are case-sensitive.
*/
function normalizeCommandText(value: string, foldCase: boolean): string {
const text = value
.replaceAll('\\"', '"')
.replaceAll("\\\\", "\\")
.replaceAll("/", "\\");
return foldCase ? text.toLowerCase() : text;
}
/**
@ -276,13 +291,15 @@ function commandMatches(command: string, persisted: PersistedService): boolean {
}
const workspace = persisted.workspaceDir.trim();
if (!workspace) return false;
const text = normalizeCommandText(command);
const marker = `workspace_dir=${normalizeCommandText(workspace)}`;
const foldCase = isWindowsPath(workspace);
const text = normalizeCommandText(command, foldCase);
const marker = `workspace_dir=${normalizeCommandText(workspace, foldCase)}`;
const index = text.indexOf(marker);
if (index < 0) return false;
// Require a boundary so `C:\a` does not match another service using `C:\ab`.
// A backslash remains a boundary for the `\"` form older shells produce.
const next = text[index + marker.length] ?? "";
if (next && !/[\s"',\]]/.test(next)) return false;
if (next && !/[\s"',\]\\]/.test(next)) return false;
return new RegExp(`service\\.port=${port}(?!\\d)`).test(text);
}
@ -444,6 +461,7 @@ export async function startService(
logPath: options.logPath,
startedAt,
error: `Could not start the ReMe service: ${safeError(error)}`,
code: "start-failed",
};
}
let child: { pid: number | undefined };
@ -468,6 +486,7 @@ export async function startService(
logPath: options.logPath,
startedAt,
error: `Could not start the ReMe service: ${safeError(error)}`,
code: "start-failed",
};
}
try {
@ -494,6 +513,7 @@ export async function startService(
logPath: options.logPath,
startedAt,
error: `Could not record the ReMe service: ${safeError(error)}`,
code: "start-failed",
};
}
return {
@ -556,7 +576,11 @@ export async function stopService(
const persisted = readServiceState(options.statePath);
if (!persisted || !isValidPid(persisted.pid)) {
if (persisted) clearServiceState(options.statePath);
return { stopped: false, reason: "No service started by this plugin" };
return {
stopped: false,
reason: "No service started by this plugin",
code: "no-record",
};
}
const ownership =
options.ownership ??
@ -566,11 +590,13 @@ export async function stopService(
stopped: false,
reason:
"The recorded process identity could not be verified; refusing to stop it",
code: "unverified",
};
if (ownership !== "owned")
return {
stopped: false,
reason: "The recorded service does not belong to this endpoint",
code: "foreign",
};
const pid = persisted.pid;
const pollMs = options.pollMs ?? STOP_POLL_MS;
@ -607,6 +633,7 @@ export async function stopService(
stopped: false,
pid,
reason: "The service did not exit after the force-kill",
code: "not-exited",
};
}
@ -681,8 +708,25 @@ export function createServiceIO(): ServiceIO {
stdio: ["ignore", fd, fd],
});
await new Promise<void>((resolve, reject) => {
child.once("spawn", () => resolve());
child.once("error", (error: Error) => reject(error));
// A spawn acknowledgement that never arrives must not block the
// start queue (and therefore a queued Stop) forever.
const timer = setTimeout(() => {
try {
child.kill();
} catch {
// Already gone.
}
reject(new Error("spawn did not acknowledge within 10 s"));
}, 10_000);
timer.unref();
child.once("spawn", () => {
clearTimeout(timer);
resolve();
});
child.once("error", (error: Error) => {
clearTimeout(timer);
reject(error);
});
});
} finally {
// The child owns its inherited handle; the parent copy is no longer

View file

@ -67,6 +67,8 @@ export interface InstallRequest {
pythonPath: string;
/** Upgrade an existing installation to the newest satisfying version. */
upgrade?: boolean;
/** Reinstall even when pip already considers the requirement satisfied. */
forceReinstall?: boolean;
/** Optional package index URL (mirror); empty keeps the user's pip config. */
indexUrl?: string;
}
@ -133,6 +135,24 @@ export interface ServiceState {
logPath: string;
}
/** Stable reason for a failed start, so the client can localize it. */
export type ServiceStartErrorCode =
| "other-endpoint-starting"
| "conflict"
| "other-endpoint-running"
| "previous-not-stopped"
| "no-python"
| "not-installed"
| "version-too-old"
| "start-failed";
/** Stable reason for a refused/unsuccessful stop, so the client can localize it. */
export type ServiceStopReasonCode =
| "no-record"
| "foreign"
| "unverified"
| "not-exited";
export interface ServiceStartResult {
started: boolean;
alreadyRunning: boolean;
@ -141,7 +161,9 @@ export interface ServiceStartResult {
command: string;
logPath: string;
startedAt: string;
/** English diagnostic; shown only when no known `code` applies. */
error?: string;
code?: ServiceStartErrorCode;
}
export interface ServiceLog {

View file

@ -145,6 +145,12 @@ test("composes pip arguments for install, upgrade and mirrors", () => {
"reme-ai[core]",
],
);
assert.ok(
pipArgs({
pythonPath: "C:\\env\\python.exe",
forceReinstall: true,
}).includes("--force-reinstall"),
);
assert.throws(() =>
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
);

View file

@ -4,6 +4,7 @@ import {
closeSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
@ -12,7 +13,10 @@ 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 {
openAppendLog,
pruneInstallLogs,
} 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 () => {
@ -74,3 +78,16 @@ test("an oversized log is rotated before the next append", (t) => {
assert.equal(readFileSync(`${path}.1`, "utf8").length, 200);
assert.equal(statSync(path).size, 0);
});
test("per-job install logs are pruned to the newest files", (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-prune-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
for (let index = 0; index < 25; index += 1)
writeFileSync(join(directory, `install-${index}.log`), "x");
pruneInstallLogs(directory, 20);
const remaining = readdirSync(directory).filter((name) =>
name.endsWith(".log"),
);
assert.equal(remaining.length, 20);
});

View file

@ -283,6 +283,18 @@ test("a recycled pid that is no longer the service is not managed", async (t) =>
}
});
test("recognizes the Windows wrapper command line with escaped quotes", async (t) => {
const { statePath } = tempState(t, "reme-wrapper-");
record(statePath);
const state = readServiceState(statePath);
// What Windows shows for `python -c <wrapper>`: the JSON quotes are escaped
// as `\"` and each path separator appears doubled.
const escaped = state.workspaceDir.replaceAll("\\", "\\\\");
const command = `python -c "import sys;sys.argv=[\\"reme\\",\\"start\\",\\"workspace_dir=${escaped}\\",\\"service.port=2334\\"];from reme.reme import main"`;
const { io } = fakeIO({ processCommandLine: async () => command });
assert.equal(await serviceOwnership(io, state, ENDPOINT), "owned");
});
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
const { directory, statePath } = tempState(t, "reme-dead-");
record(statePath);
@ -373,6 +385,7 @@ test("stops the managed pid and clears the state file after it exits", async (t)
assert.deepEqual(await stopService(io, { statePath, endpoint: ENDPOINT }), {
stopped: false,
reason: "No service started by this plugin",
code: "no-record",
});
});

View file

@ -49,6 +49,7 @@ const RESPONSES = {
versionOk: true,
hasCore: true,
cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`,
error: "",
},
"remeEnv/installStatus": null,
"remeEnv/readEnv": {
@ -147,9 +148,24 @@ async function mount(overrides = {}, options = {}) {
},
};
const rpc = {
call: async (_path, method, payload) => {
call: async (_path, method, payload, signal) => {
calls.rpc.push({ method, input: payload?.args?.input });
return { ok: true, value: responses[method] ?? null };
if (options.pending?.includes(method))
return new Promise((_resolve, reject) => {
signal?.addEventListener(
"abort",
() => reject(new Error("aborted")),
{ once: true },
);
});
const value = responses[method];
return {
ok: true,
value:
typeof value === "function"
? value(payload?.args?.input)
: value ?? null,
};
},
};
@ -289,6 +305,7 @@ test("sends the chosen package mirror with the install request", async (t) => {
pythonPath: SUGGESTED,
upgrade: false,
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
forceReinstall: true,
});
});
@ -406,6 +423,7 @@ test("shows why Stop left the service alone", async (t) => {
"remeEnv/serviceStop": {
stopped: false,
reason: "The recorded process identity could not be verified",
code: "unverified",
},
},
},
@ -420,7 +438,10 @@ test("shows why Stop left the service alone", async (t) => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
assert.match(texts(renderer.toJSON()).join(" "), /could not be verified/);
// The Host code is localized in the UI instead of leaking its English text.
assert.ok(
texts(renderer.toJSON()).join(" ").includes(envZh.stopUnknownIdentity),
);
});
test("surfaces a rejected settings write", async (t) => {
@ -438,3 +459,283 @@ test("surfaces a rejected settings write", async (t) => {
});
assert.match(texts(renderer.toJSON()).join(" "), /设置未保存/);
});
test("an aborted LLM test releases its button", async (t) => {
const { renderer, setValue, restore } = await mount(
{},
{ pending: ["remeEnv/testLlm"] },
);
t.after(restore);
await act(async () => {
buttonByText(renderer, envZh.expand).props.onClick();
});
await act(async () => {
buttonByText(renderer, envZh.testLlmButton).props.onClick();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
assert.ok(texts(renderer.toJSON()).join(" ").includes(envZh.testingLlm));
await act(async () => {
setValue({ remeWorkspaceDir: "/tmp/second-workspace" });
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
const copy = texts(renderer.toJSON()).join(" ");
assert.equal(copy.includes(envZh.testingLlm), false);
assert.ok(copy.includes(envZh.testLlmButton), "the button is usable again");
});
test("a start that died before listening reports immediately", async (t) => {
const stopped = {
phase: "stopped",
managed: false,
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
};
const { renderer, restore } = await mount(
{},
{
responses: {
"remeEnv/serviceStatus": stopped,
"remeEnv/serviceStart": {
started: true,
alreadyRunning: false,
command: "",
logPath: stopped.logPath,
startedAt: new Date().toISOString(),
},
},
},
);
t.after(restore);
await act(async () => {
buttonByText(renderer, envZh.startService).props.onClick();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1_200));
});
const copy = texts(renderer.toJSON()).join(" ");
assert.ok(copy.includes(envZh.startFailedStopped));
// Ready to retry without waiting out the 120 s deadline.
assert.ok(buttonByText(renderer, envZh.startService));
});
test("a start that lands on a foreign conflict shows the conflict state", async (t) => {
let calls = 0;
const states = [
{
phase: "stopped",
managed: false,
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
},
{
phase: "conflict",
managed: false,
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
},
];
const { renderer, restore } = await mount(
{},
{
responses: {
"remeEnv/serviceStatus": () =>
states[Math.min(calls++, states.length - 1)],
"remeEnv/serviceStart": {
started: true,
alreadyRunning: false,
command: "",
logPath: states[0].logPath,
startedAt: new Date().toISOString(),
},
},
},
);
t.after(restore);
await act(async () => {
buttonByText(renderer, envZh.startService).props.onClick();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1_200));
});
assert.ok(texts(renderer.toJSON()).join(" ").includes(envZh.conflictNote));
});
test("Reinstall forces a reinstall while install and upgrade use the right flags", async () => {
const upgrade = await mount(
{},
{
responses: {
"remeEnv/checkReme": {
installed: true,
version: "0.3.1.8",
versionOk: false,
hasCore: false,
cliPath: "",
error: "",
},
},
},
);
await act(async () => {
buttonByText(upgrade.renderer, envZh.upgradeButton).props.onClick();
});
await act(async () => {
buttonByText(upgrade.renderer, envZh.confirmInstall).props.onClick();
});
const upgradeCall = upgrade.calls.rpc
.filter((call) => call.method === "remeEnv/install")
.at(-1);
assert.deepEqual(upgradeCall.input, {
pythonPath: SUGGESTED,
upgrade: true,
});
upgrade.restore();
const fresh = await mount(
{},
{
responses: {
"remeEnv/checkReme": {
installed: false,
version: "",
versionOk: false,
hasCore: false,
cliPath: "",
error: "",
},
},
},
);
await act(async () => {
buttonByText(fresh.renderer, envZh.installButton).props.onClick();
});
await act(async () => {
buttonByText(fresh.renderer, envZh.confirmInstall).props.onClick();
});
const freshCall = fresh.calls.rpc
.filter((call) => call.method === "remeEnv/install")
.at(-1);
assert.deepEqual(freshCall.input, {
pythonPath: SUGGESTED,
upgrade: false,
});
fresh.restore();
const reinstall = await mount();
await act(async () => {
buttonByText(reinstall.renderer, envZh.reinstall).props.onClick();
});
await act(async () => {
buttonByText(reinstall.renderer, envZh.confirmInstall).props.onClick();
});
const reinstallCall = reinstall.calls.rpc
.filter((call) => call.method === "remeEnv/install")
.at(-1);
assert.deepEqual(reinstallCall.input, {
pythonPath: SUGGESTED,
upgrade: false,
forceReinstall: true,
});
reinstall.restore();
});
test("localizes a coded start error instead of showing Host English", async (t) => {
const stopped = {
phase: "stopped",
managed: false,
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
};
const { renderer, restore } = await mount(
{},
{
responses: {
"remeEnv/serviceStatus": stopped,
"remeEnv/serviceStart": {
started: false,
alreadyRunning: false,
error: "Another process is already listening on this endpoint",
code: "conflict",
command: "",
logPath: stopped.logPath,
startedAt: new Date().toISOString(),
},
},
},
);
t.after(restore);
await act(async () => {
buttonByText(renderer, envZh.startService).props.onClick();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const copy = texts(renderer.toJSON()).join(" ");
assert.ok(copy.includes(envZh.startErrorConflict));
assert.equal(copy.includes("Another process is already listening"), false);
});
test("switching the endpoint aborts the previous start poll", async (t) => {
const OLD = "http://127.0.0.1:2333";
const NEW = "http://127.0.0.1:2444";
const stopped = {
phase: "stopped",
managed: false,
endpoint: OLD,
logPath: "C:\\Users\\test\\.reme\\service.log",
};
const newRunning = {
phase: "running",
managed: true,
pid: 1111,
endpoint: NEW,
version: "9.9.9",
logPath: "C:\\Users\\test\\.reme\\service.log",
};
const oldRunning = {
...newRunning,
pid: 5000,
endpoint: OLD,
version: "0.4.0.9",
};
let statusCalls = 0;
const { renderer, setValue, restore } = await mount(
{},
{
responses: {
"remeEnv/serviceStatus": (input) => {
if (input?.endpoint === NEW) return newRunning;
statusCalls += 1;
return statusCalls === 1 ? stopped : oldRunning;
},
"remeEnv/serviceStart": {
started: true,
alreadyRunning: false,
command: "",
logPath: stopped.logPath,
startedAt: new Date().toISOString(),
},
},
},
);
t.after(restore);
await act(async () => {
buttonByText(renderer, envZh.startService).props.onClick();
});
// Switch before the poll's 1 s tick: it must not paint the old endpoint's
// running data under the new URL.
await act(async () => {
setValue({ endpoint: NEW });
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1_200));
});
const copy = texts(renderer.toJSON()).join(" ");
assert.ok(copy.includes("PID 1111"), "the new endpoint's pid is shown");
assert.equal(copy.includes("PID 5000"), false);
});