From 879ed766ca3142d26e8687bf84b851c61a6165c1 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 05:22:13 +0800 Subject: [PATCH 1/3] emrg: the GUI honours the start window the TUI honours (#1276) --- Agent.md | 2 +- emrg/gui/daemon_client.js | 45 ++++++++-- emrg/gui/test/daemon_client.test.js | 126 ++++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/Agent.md b/Agent.md index 9560e20e..b880c48c 100644 --- a/Agent.md +++ b/Agent.md @@ -56,7 +56,7 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. The *why* behind each guard is in its docstring and `--help`, not here. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored** (a stored count is a conflict magnet that goes stale silently): `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (127: 71 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` +GUI: `cd emrg/gui && npm test` (133: 77 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (516: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 19 daemonBridge + 7 DaemonBridgeProvider + 31 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push event with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (what merging dirties) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes the guards) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 6bf0e73a..151e3a66 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -39,7 +39,14 @@ const EMRGD_START_ERR = () => path.join(os.homedir(), ".emrg", "emrgd-start.err" const EMRGD_PORT = 56031; const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具输出上限 200KB) const AUTH_TIMEOUT_MS = 10_000; +// Issue #1276 item 5:启动等待窗口。GUI 自己的默认值保持 5.0s 不变(改默认值是一次 +// 行为变更,而"冷启动是否真需要更久"没有被测量过);变的是它现在**可被宿主抬高**—— +// 与 emrg/client/daemon_manager.py 的 `EMRG_START_TIMEOUT` 同一个变量、同一套回落规则 +// (见 `_startWindowMs`)。此前 GUI 只有这个源码常量,而它被打包进 app.asar:最需要 +// 抬高窗口的宿主(不用终端的那些人)恰恰是唯一够不着它的人。 const SPAWN_WAIT_MS = 5_000; +const SPAWN_WAIT_POLL_MS = 300; +const START_WINDOW_ENV = "EMRG_START_TIMEOUT"; const PENDING_TIMEOUT_MS = 5_000; // Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):单个"连接生命周期"内最多 spawn // MAX_SPAWN_ATTEMPTS 次 daemon——之后不再拉起,只把真实错误(含 emrgd.log 尾部) @@ -350,14 +357,42 @@ class DaemonClient { this._startupFailureDetail(mark, child, spawnState, stderrFile) ); } - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, SPAWN_WAIT_POLL_MS)); } throw new Error( - `emrgd failed to start within timeout` + + // 报**真的等过**的那个界,不是宿主敲的那个数:窗口按 0.3s 的轮询量化,与 + // daemon_manager.py 的 `failed to start within {attempts * delay:.1f}s` 同一句法, + // 于是两条入口的失败文案能被同一句话描述。 + `emrgd failed to start within ${(waitMs / 1000).toFixed(1)}s` + this._startupFailureDetail(mark, child, spawnState, stderrFile) ); } + /** 启动等待窗口(毫秒):`EMRG_START_TIMEOUT`(秒)若已设置,否则 `SPAWN_WAIT_MS`。 + * + * issue #1276 item 5 的 GUI 半边。这是 emrg/client/daemon_manager.py + * `_start_window_seconds()` 的对照实现,读**同一个**环境变量、用同一套回落规则 + * ——宿主给 TUI 设过的值对 GUI 同样生效,而不是两条入口各有一个只有源码能改的窗口。 + * + * 取值不合法时回落到默认并告警,**绝不抛**:与 `_truncate_start_stderr` 同一条规则 + * ——一个诊断/调参用的变量不得成为启动失败的原因。`Number()` 的可用性在此处够用: + * 空串与非数字都是 NaN(空串在下面被单独当作"未设置"先返回),`Infinity`/`nan` 被 + * `Number.isFinite` 挡下,非正数被 `<= 0` 挡下 —— 与 Python 侧拒绝的是同一批形态。 + */ + _startWindowMs(env = process.env) { + const raw = String(env[START_WINDOW_ENV] ?? "").trim(); + if (!raw) return SPAWN_WAIT_MS; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) { + this.logger.warn( + `${START_WINDOW_ENV}='${raw}' is not a positive number of seconds — ` + + `using ${SPAWN_WAIT_MS / 1000}s` + ); + return SPAWN_WAIT_MS; + } + return seconds * 1000; + } + async startDaemon() { // Rant 2026-08-09T13:16:36 ⑤:spawn 节流——超过上限不再拉起(防窗口/重试风暴)。 if (this._spawnAttempts >= MAX_SPAWN_ATTEMPTS) { @@ -402,7 +437,7 @@ class DaemonClient { // 只有真开出了诊断文件才算"读过这一路":拿不到 fd 时子进程的 stderr 退回 // "ignore",报告必须说"未捕获",而不是替这一路宣布沉默。 return await this._awaitDaemonReady( - child, mark, SPAWN_WAIT_MS, spawnState, + child, mark, this._startWindowMs(), spawnState, errFd === null ? null : EMRGD_START_ERR() ); } @@ -425,10 +460,10 @@ class DaemonClient { child.unref(); // GUI 退出不带走 daemon this._daemonChild = child; // 暴露 child(集成测试 after 清理用) this.logger.info(`[gui] daemon spawned: pid=${child.pid} (source mode)`); // 18:47:37 B2 - // 等最多 SPAWN_WAIT_MS 就绪 + // 等最多 _startWindowMs()(宿主可用 EMRG_START_TIMEOUT 抬高)就绪 const spawnState = this._watchSpawn(child); return await this._awaitDaemonReady( - child, mark, SPAWN_WAIT_MS, spawnState, + child, mark, this._startWindowMs(), spawnState, errFdSource === null ? null : EMRGD_START_ERR() ); } diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 04bbd721..230d8956 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -490,10 +490,10 @@ test("rant 13:16:36 ⑤ spawn 节流:超 MAX_SPAWN_ATTEMPTS 后不再拉起 da this._spawnAttempts += 1; spawnCount += 1; await new Promise((r) => setTimeout(r, 5)); - throw new Error("emrgd failed to start within timeout"); + throw new Error("emrgd failed to start within 5.0s"); }; for (let i = 0; i < 3; i++) { - await assert.rejects(client.startDaemon(), /emrgd failed to start within timeout/); + await assert.rejects(client.startDaemon(), /emrgd failed to start within 5\.0s/); } assert.strictEqual(spawnCount, 3, "3 次尝试内每次都会真正 spawn"); // 第 4 次:不再 spawn,直接抛节流错误 @@ -508,10 +508,10 @@ test("rant 13:16:36 ⑤ spawn 节流计数在成功连接后归零", async () => client.startDaemon = async function () { this._spawnAttempts += 1; // 镜像真实 startDaemon 的计数 await new Promise((r) => setTimeout(r, 10)); - throw new Error("emrgd failed to start within timeout"); + throw new Error("emrgd failed to start within 5.0s"); }; client.isRunning = async () => false; - await assert.rejects(client.startDaemon(), /emrgd failed to start within timeout/); + await assert.rejects(client.startDaemon(), /emrgd failed to start within 5\.0s/); assert.strictEqual(client._spawnAttempts, 1); // 恢复真实 startDaemon(token 文件已预写 → ensureConnected 直接 ws → auth_ok) delete client.startDaemon; @@ -1153,7 +1153,7 @@ test("#1283 非 number 的 exitCode 不得读成“已退出”(只认文档 client.isRunning = async () => false; await assert.rejects( client._awaitDaemonReady({ exitCode: "7" }, client._logMark(logFile()), 1), - (err) => err.message.includes("failed to start within timeout"), + (err) => err.message.includes("failed to start within "), ); }); @@ -1162,7 +1162,7 @@ test("#1283 活着的子进程没起来 → 报窗口 + still running", async () client.isRunning = async () => false; await assert.rejects( client._awaitDaemonReady({ exitCode: null }, client._logMark(logFile()), 1), - (err) => err.message.includes("failed to start within timeout") && err.message.includes("still running"), + (err) => err.message.includes("failed to start within ") && err.message.includes("still running"), ); }); @@ -1173,6 +1173,116 @@ test("#1283 子进程起来了 → 安静返回", async () => { assert.strictEqual(await client._awaitDaemonReady(child, client._logMark(logFile()), 1000), child); }); +// ── issue #1276 item 5:GUI 也认 EMRG_START_TIMEOUT ────────────────────────── +// 对照 emrg/client/daemon_manager.py 的 `_start_window_seconds()`:同一个变量、同一套 +// 回落规则。GUI 是**非开发者宿主**的入口,而窗口此前只有 SPAWN_WAIT_MS 这一个源码 +// 常量,且被打包进 app.asar —— 最需要抬高它的宿主恰恰是唯一够不着它的人。 +// 这里不 spawn 任何 daemon:启动路径的测试一律把 spawn 打桩(MANIFESTO 第四条附则二)。 + +test("#1276 GUI:窗口未设置时用 GUI 自己的默认值(5000ms)", () => { + const client = new DaemonClient(); + assert.strictEqual(client._startWindowMs({}), 5_000); + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: "" }), 5_000, "空串 = 未设置"); + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: " " }), 5_000, "空白 = 未设置"); +}); + +test("#1276 GUI:设置后按秒换算(整数、小数、前后空白)", () => { + const client = new DaemonClient(); + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: "12" }), 12_000); + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: "4.5" }), 4_500); + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: " 5 " }), 5_000); +}); + +test("#1276 GUI:非正/非有限/非数字 → 回落默认并告警(调参不得成为启动失败的原因)", () => { + const warnings = []; + const client = new DaemonClient({ + logger: { info: () => {}, warn: (m) => warnings.push(m) }, + }); + for (const bad of ["abc", "0", "-5", "nan", "Infinity", "5s"]) { + assert.strictEqual( + client._startWindowMs({ EMRG_START_TIMEOUT: bad }), 5_000, `${bad} → 默认`, + ); + } + assert.strictEqual(warnings.length, 6, "每个坏值一次告警"); + assert.ok( + warnings[0].includes("EMRG_START_TIMEOUT='abc'"), + `告警须点名变量与值,宿主才知道是哪个变量:${warnings[0]}`, + ); +}); + +test("#1276 GUI:真实 spawn 路径把解析出的窗口交给等待(spawn 打桩,不拉起 daemon)", async () => { + const childProcess = require("child_process"); + const { EventEmitter } = require("events"); + const origSpawn = childProcess.spawn; + const origTimeout = process.env.EMRG_START_TIMEOUT; + try { + childProcess.spawn = () => { + const child = new EventEmitter(); + child.unref = () => {}; + child.pid = 4242; + child.exitCode = null; + child.signalCode = null; + return child; + }; + const client = new DaemonClient(); + let seen = null; + client._awaitDaemonReady = async (child, mark, waitMs) => { seen = waitMs; return child; }; + process.env.EMRG_START_TIMEOUT = "12"; + await client.startDaemon(); + assert.strictEqual(seen, 12_000, "12 → 12000ms,且是启动那一刻读的"); + delete process.env.EMRG_START_TIMEOUT; + await client.startDaemon(); + assert.strictEqual(seen, 5_000, "未设置 → GUI 默认 5000ms"); + } finally { + childProcess.spawn = origSpawn; + if (origTimeout === undefined) delete process.env.EMRG_START_TIMEOUT; + else process.env.EMRG_START_TIMEOUT = origTimeout; + } +}); + +test("#1276 GUI:打包模式的 spawn 路径同样吃这个窗口(spawn 打桩,不拉起 daemon)", async () => { + const childProcess = require("child_process"); + const { EventEmitter } = require("events"); + const origSpawn = childProcess.spawn; + const origTimeout = process.env.EMRG_START_TIMEOUT; + try { + childProcess.spawn = () => { + const child = new EventEmitter(); + child.unref = () => {}; + child.pid = 4242; + child.exitCode = null; + child.signalCode = null; + return child; + }; + // 打包模式是**非开发者宿主**真正走的那条路(app.asar 里的 emrgd),所以它单独 + // 钉一次:只改源码模式那一处的变异臂,在这条测试存在之前是查不出来的。 + const client = new DaemonClient({ isPackaged: true }); + let seen = null; + client._awaitDaemonReady = async (child, mark, waitMs) => { seen = waitMs; return child; }; + process.env.EMRG_START_TIMEOUT = "12"; + await client.startDaemon(); + assert.strictEqual(seen, 12_000, "打包路径同样把解析出的窗口交给等待"); + } finally { + childProcess.spawn = origSpawn; + if (origTimeout === undefined) delete process.env.EMRG_START_TIMEOUT; + else process.env.EMRG_START_TIMEOUT = origTimeout; + } +}); + +test("#1276 GUI:失败文案报真的等过的窗口,不是敲进去的数", async () => { + const client = new DaemonClient(); + client.isRunning = async () => false; + const t0 = Date.now(); + await assert.rejects( + client._awaitDaemonReady( + { exitCode: null }, client._logMark(logFile()), + client._startWindowMs({ EMRG_START_TIMEOUT: "0.3" }), + ), + (err) => err.message.includes("failed to start within 0.3s"), + ); + assert.ok(Date.now() - t0 < 2000, "0.3s 的窗口不该被烧成别的数"); +}); + // ── 子进程自己的 stderr:装日志 handler 之前就死掉的失败只有这一个出口 ──────── // 对照 emrg/client/daemon_manager.py 的 `_truncate_start_stderr` / `_read_start_stderr` // (issue #1276 item 4)。这里不 spawn 任何 daemon:直接开文件、写文件、读回来。 @@ -1327,7 +1437,7 @@ test("#1283 从未启动 → never started,绝不是 still running,也不烧 client._awaitDaemonReady(child, mark, 5_000, state), (err) => err.message.includes("never started") && !err.message.includes("still running") - && !err.message.includes("failed to start within timeout") + && !err.message.includes("failed to start within ") && !err.message.includes("exited during startup"), ); assert.ok(Date.now() - t0 < 1000, "5s 的窗口不该被烧完"); @@ -1378,7 +1488,7 @@ test("#1283 startDaemon:spawn 打桩为 ENOENT → 立即失败并点名,不 c.startDaemon(), (err) => err.message.includes("never started (ENOENT)") && !err.message.includes("still running") - && !err.message.includes("failed to start within timeout"), + && !err.message.includes("failed to start within "), ); assert.ok(Date.now() - t0 < 1000, "ENOENT 不该烧满 5s 窗口"); } finally { From 105f69f3618b6f3a906b6ed3f1e1b527cfdfd4c5 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 06:58:48 +0800 Subject: [PATCH 2/3] emrg: one duration, one shape, in both entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1404's claim is that the GUI honours the start window the TUI honours. Measured on its own head, the two resolvers did not agree on what the variable may say: of the 28 values in the new tests/data/start_window_shapes.json, 7 got different answers. `Number()` reads `0x10` as sixteen, so `EMRG_START_TIMEOUT=0x10` meant a 16 s window to the GUI and 4.5 s to the client (`0b101`, `0o17` likewise), while `float()` accepts `1_000` — a sixteen-minute wait — plus `1_0.5` and full-width/Arabic-Indic digits. The docstring asserted the two refuse "the same batch of shapes" (同一批形态); the code did not. Both sides now name one shape — `_START_WINDOW_SHAPE` in daemon_manager.py and `START_WINDOW_SHAPE` in daemon_client.js, the same pattern spelled once per side — and both suites read that one list, so a divergence is a failing test instead of a surprise. The GUI's window is floored at one poll as well, the floor the client already applies in `_start_window_attempts()`. Without it `EMRG_START_TIMEOUT=0.01` really waited ~0.3 s and then reported "within 0.0s" — a number naming a window it never waited for (measured: 303 ms). Its docstring's claim that the GUI quantises to the 0.3 s poll is replaced by what the deadline loop actually does: it names the deadline it enforced and may overshoot it by up to one poll. Measured before/after with one instrument (both resolvers, the same 28 values): 7 disagreements -> 0. GUI daemon_client 77 -> 79 pass; the client's own test file 39 pass. --- Agent.md | 2 +- emrg/client/daemon_manager.py | 31 +++++++++---- emrg/gui/daemon_client.js | 40 ++++++++++++++--- emrg/gui/test/daemon_client.test.js | 54 +++++++++++++++++++++- tests/data/start_window_shapes.json | 60 +++++++++++++++++++++++++ tests/test_daemon_start_diagnostics.py | 62 ++++++++++++++++++++++++++ 6 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 tests/data/start_window_shapes.json diff --git a/Agent.md b/Agent.md index b880c48c..2173e8e3 100644 --- a/Agent.md +++ b/Agent.md @@ -56,7 +56,7 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. The *why* behind each guard is in its docstring and `--help`, not here. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored** (a stored count is a conflict magnet that goes stale silently): `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (133: 77 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` +GUI: `cd emrg/gui && npm test` (135: 79 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (516: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 19 daemonBridge + 7 DaemonBridgeProvider + 31 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push event with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (what merging dirties) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes the guards) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` diff --git a/emrg/client/daemon_manager.py b/emrg/client/daemon_manager.py index 3b796633..3c7d277c 100644 --- a/emrg/client/daemon_manager.py +++ b/emrg/client/daemon_manager.py @@ -15,6 +15,7 @@ import logging import math import os +import re import signal import subprocess import sys @@ -350,6 +351,18 @@ def _startup_failure_detail( _START_WINDOW_ENV = "EMRG_START_TIMEOUT" _START_WINDOW_DEFAULT_SECONDS = 4.5 _START_WINDOW_POLL_SECONDS = 0.3 +# The shape a duration is written in: a plain decimal number, ASCII digits only. +# +# Spelled out here rather than left to `float()`, because "float-shaped" is not the +# same test as "a duration", and because `float()`'s accepted set is *not* the one the +# GUI's `Number()` implements — so the two entry points disagreed on 7 of the 28 values +# in `tests/data/start_window_shapes.json` (measured on #1404's head: `0x10` is sixteen +# to `Number()` and a typo to `float()`; `1_000` is a thousand seconds to `float()` and +# a typo to `Number()`; `4` and `٣` are digits to `float()` and letters to `Number()`). +# Both sides now name this same shape, and both suites read that one list: a value the +# GUI refuses is a value the client refuses, or "the two read the same variable" is a +# sentence about a variable rather than about the window it is supposed to set. +_START_WINDOW_SHAPE = re.compile(r"[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?") def _start_window_seconds() -> float: @@ -365,23 +378,25 @@ def _start_window_seconds() -> float: fail**, and a typo in a tuning variable would be exactly that. It is logged once, where a host who is reading the client log can see it. - Two shapes that parse as a float are rejected anyway, because "float-shaped" - is not the same test as "a duration": a non-positive number would produce a - loop that never waits and then reports a start it never waited for, and a - non-finite one (`inf`, `nan`) raises inside the arithmetic that turns seconds - into polls. Whitespace-only counts as unset — that is how a shell spells it. + "Malformed" is decided by ``_START_WINDOW_SHAPE``, not by whether the string + parses: `1_000` parses (and would wait sixteen minutes), `4` parses (and would + wait four seconds) — both are typos, and both are refused. What is left for the + sign test below: a non-positive number would produce a loop that never waits and + then reports a start it never waited for. (`inf` / `nan` cannot reach it any + more — the shape has no spelling for them — and the ``isfinite`` check stays as + the arithmetic's own guard.) Whitespace-only counts as unset — that is how a + shell spells it. """ raw = (os.environ.get(_START_WINDOW_ENV) or "").strip() if not raw: return _START_WINDOW_DEFAULT_SECONDS - try: - seconds = float(raw) - except ValueError: + if not _START_WINDOW_SHAPE.fullmatch(raw): logger.warning( "%s=%r is not a number — using %.1fs", _START_WINDOW_ENV, raw, _START_WINDOW_DEFAULT_SECONDS, ) return _START_WINDOW_DEFAULT_SECONDS + seconds = float(raw) if not math.isfinite(seconds) or seconds <= 0: logger.warning( "%s=%r is not a positive number of seconds — using %.1fs", diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 151e3a66..cdd19d24 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -47,6 +47,10 @@ const AUTH_TIMEOUT_MS = 10_000; const SPAWN_WAIT_MS = 5_000; const SPAWN_WAIT_POLL_MS = 300; const START_WINDOW_ENV = "EMRG_START_TIMEOUT"; +// 一个"时长"写成的形态:纯十进制数,只认 ASCII 数字。与 emrg/client/daemon_manager.py +// 的 `_START_WINDOW_SHAPE` 是同一个形态(那边用 re.fullmatch,这边锚定 ^...$), +// 两侧的测试读同一份清单 `tests/data/start_window_shapes.json`。 +const START_WINDOW_SHAPE = /^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/; const PENDING_TIMEOUT_MS = 5_000; // Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):单个"连接生命周期"内最多 spawn // MAX_SPAWN_ATTEMPTS 次 daemon——之后不再拉起,只把真实错误(含 emrgd.log 尾部) @@ -360,9 +364,15 @@ class DaemonClient { await new Promise((r) => setTimeout(r, SPAWN_WAIT_POLL_MS)); } throw new Error( - // 报**真的等过**的那个界,不是宿主敲的那个数:窗口按 0.3s 的轮询量化,与 - // daemon_manager.py 的 `failed to start within {attempts * delay:.1f}s` 同一句法, - // 于是两条入口的失败文案能被同一句话描述。 + // 报的是这个循环真正守住的那个界:`deadline` 就是 `Date.now() + waitMs`,而 + // `waitMs` 已被 `_startWindowMs` 抬到至少一个轮询(0.3s)——所以它是一句能为真 + // 的话,且与 daemon_manager.py 的 `failed to start within {attempts * delay:.1f}s` + // 同一句法,两条入口的失败文案能被同一句话描述。 + // + // 量化的部分要说清:客户端把窗口量化成 `attempts` 个轮询、报的是那个乘积;这条 + // 路径守的是 deadline,**可以多等最多一个轮询**(先探测、后睡,睡完才回到条件), + // 实测 asked 1.0 → 实际 1205ms、报 1.0s。所以这个数是下界,不是一个精确值—— + // 两种口径各自为真,值不必相等(同一个 1.0,客户端报 0.9s)。 `emrgd failed to start within ${(waitMs / 1000).toFixed(1)}s` + this._startupFailureDetail(mark, child, spawnState, stderrFile) ); @@ -375,13 +385,29 @@ class DaemonClient { * ——宿主给 TUI 设过的值对 GUI 同样生效,而不是两条入口各有一个只有源码能改的窗口。 * * 取值不合法时回落到默认并告警,**绝不抛**:与 `_truncate_start_stderr` 同一条规则 - * ——一个诊断/调参用的变量不得成为启动失败的原因。`Number()` 的可用性在此处够用: - * 空串与非数字都是 NaN(空串在下面被单独当作"未设置"先返回),`Infinity`/`nan` 被 - * `Number.isFinite` 挡下,非正数被 `<= 0` 挡下 —— 与 Python 侧拒绝的是同一批形态。 + * ——一个诊断/调参用的变量不得成为启动失败的原因。 + * + * 形态由 `START_WINDOW_SHAPE` 判定,**不用裸 `Number()`**:`Number()` 认识 + * `0x10`(宿主的笔误在 GUI 这边变成 16s 的窗口,而客户端读同一句回落 4.5s)、 + * `0b101`、`0o17` 与 `Infinity`,而 Python 的 `float()` 认识 `1_000`(一千秒)与 + * `4`/`٣` 这类别的数字系统。两侧各写一处同一个形态,两侧的测试读同一份清单 + * (`tests/data/start_window_shapes.json`)——在一个测过的形态集合上,"两条入口读 + * 同一个变量"才是关于窗口的句子,而不是关于变量名的句子。 + * + * 下界是一个轮询,与客户端 `_start_window_attempts()` 的 `max(1, ...)` 相同。 + * 没有它,`EMRG_START_TIMEOUT=0.01` 会真的等 ~0.3s,报告却说 "within 0.0s" + * ——那句话点名了一个它没有等过的界(实测:10ms 的窗口,实际 303ms)。 */ _startWindowMs(env = process.env) { const raw = String(env[START_WINDOW_ENV] ?? "").trim(); if (!raw) return SPAWN_WAIT_MS; + if (!START_WINDOW_SHAPE.test(raw)) { + this.logger.warn( + `${START_WINDOW_ENV}='${raw}' is not a number of seconds — ` + + `using ${SPAWN_WAIT_MS / 1000}s` + ); + return SPAWN_WAIT_MS; + } const seconds = Number(raw); if (!Number.isFinite(seconds) || seconds <= 0) { this.logger.warn( @@ -390,7 +416,7 @@ class DaemonClient { ); return SPAWN_WAIT_MS; } - return seconds * 1000; + return Math.max(seconds * 1000, SPAWN_WAIT_POLL_MS); } async startDaemon() { diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 230d8956..7470fa57 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -1210,8 +1210,58 @@ test("#1276 GUI:非正/非有限/非数字 → 回落默认并告警(调参 ); }); -test("#1276 GUI:真实 spawn 路径把解析出的窗口交给等待(spawn 打桩,不拉起 daemon)", async () => { - const childProcess = require("child_process"); +test("#1276 GUI:取值形态与客户端一致(同一份清单,两侧的测试都读它)", () => { + const fixture = JSON.parse( + fs.readFileSync( + path.join(__dirname, "..", "..", "..", "tests", "data", "start_window_shapes.json"), "utf8", + ), + ); + const cases = fixture.values; + const malformed = cases.filter((c) => c.kind === "malformed"); + assert.ok(cases.length >= 25, `清单缩水:${cases.length} 个取值`); + assert.ok(malformed.length >= 10, `坏值那一半缩水:${malformed.length} 个`); + + for (const c of cases) { + const warned = []; + const client = new DaemonClient({ logger: { info: () => {}, warn: (m) => warned.push(m) } }); + const ms = client._startWindowMs({ EMRG_START_TIMEOUT: c.value }); + // 清单说的是这个值**含义**(秒),两侧一致;"至少一个轮询"是各自实现里的下界, + // 见下面那条测试。客户端的下界在 `_start_window_attempts()`(max(1, ...)), + // GUI 的在 `_startWindowMs` 里 —— 位置不同,规则同一条。 + const expected = Math.max(c.seconds * 1000, 300); + if (c.kind === "duration") { + assert.ok(Math.abs(ms - expected) < 1e-6, `${c.value} 应按秒换算,得到 ${ms}`); + assert.strictEqual(warned.length, 0, `${c.value} 是合法时长,不该告警`); + } else { + assert.strictEqual(ms, 5_000, `${c.value} 应回落 GUI 自己的默认值`); + if (c.kind === "malformed") { + assert.strictEqual(warned.length, 1, `${c.value} 必须告警——静默回落会掩盖笔误`); + } else { + assert.strictEqual(warned.length, 0, `${c.value} 是"未设置"的写法,不该告警`); + } + } + } +}); + +test("#1276 GUI:报告说不出它没等过的界(低于一个轮询的窗口被抬到 0.3s)", async () => { + const client = new DaemonClient(); + client.isRunning = async () => false; + // 没有下界时:窗口 10ms,真的等 ~0.3s,文案却说 "within 0.0s" —— 实测 303ms。 + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: "0.01" }), 300); + assert.strictEqual(client._startWindowMs({ EMRG_START_TIMEOUT: "0.001" }), 300); + const t0 = Date.now(); + await assert.rejects( + client._awaitDaemonReady( + { exitCode: null }, client._logMark(logFile()), + client._startWindowMs({ EMRG_START_TIMEOUT: "0.01" }), + ), + (err) => err.message.includes("failed to start within 0.3s"), + ); + // 说 0.3s,就真的等过至少 0.3s(客户端对同一个取值也报 0.3s) + assert.ok(Date.now() - t0 >= 250, "报告是下界:不能等得比它说的还短"); +}); + +test("#1276 GUI:真实 spawn 路径把解析出的窗口交给等待(spawn 打桩,不拉起 daemon)", async () => { const childProcess = require("child_process"); const { EventEmitter } = require("events"); const origSpawn = childProcess.spawn; const origTimeout = process.env.EMRG_START_TIMEOUT; diff --git a/tests/data/start_window_shapes.json b/tests/data/start_window_shapes.json new file mode 100644 index 00000000..3793496f --- /dev/null +++ b/tests/data/start_window_shapes.json @@ -0,0 +1,60 @@ +{ + "_comment": [ + "The values EMRG_START_TIMEOUT is allowed to hold, and what each one means.", + "Read by BOTH suites, one per entry point: tests/test_daemon_start_diagnostics.py", + "(the client, emrg/client/daemon_manager.py) and emrg/gui/test/daemon_client.test.js", + "(the GUI, emrg/gui/daemon_client.js). One list, two readers - the two resolvers are", + "required to agree on the accept/refuse decision for every value here, and a list kept", + "separately on each side would be a copy that drifts silently. Issue #1276 item 5,", + "cycle cyc20260919-065231.", + "", + "kind=duration accepted; parses to `seconds`", + "kind=malformed refused with a warning in the entry point's own log, falls back to that", + " entry point's default window (the client 4.5s, the GUI 5.0s)", + "kind=unset how a shell spells 'not set'; the default window, and NO warning", + "", + "The malformed half is the point of the list. `float()` and `Number()` do not accept the", + "same strings, so 'the two entry points read the same variable' is worth nothing unless", + "they also agree on what that variable may say: measured on the head of #1404, 7 of 28", + "values disagreed (`0x10` -> the GUI waited 16s while the client fell back to 4.5s)." + ], + "values": [ + {"value": "5", "kind": "duration", "seconds": 5}, + {"value": "1.2", "kind": "duration", "seconds": 1.2}, + {"value": "30", "kind": "duration", "seconds": 30}, + {"value": "4.5", "kind": "duration", "seconds": 4.5}, + {"value": "5.", "kind": "duration", "seconds": 5}, + {"value": ".5", "kind": "duration", "seconds": 0.5}, + {"value": "+5", "kind": "duration", "seconds": 5}, + {"value": " 5 ", "kind": "duration", "seconds": 5}, + {"value": "0.01", "kind": "duration", "seconds": 0.01}, + {"value": "1e2", "kind": "duration", "seconds": 100}, + {"value": "1E2", "kind": "duration", "seconds": 100}, + {"value": "1.5e1", "kind": "duration", "seconds": 15}, + {"value": "0.5e-1", "kind": "duration", "seconds": 0.05}, + + {"value": "0x10", "kind": "malformed"}, + {"value": "0b101", "kind": "malformed"}, + {"value": "0o17", "kind": "malformed"}, + {"value": "0x1p4", "kind": "malformed"}, + {"value": "1_000", "kind": "malformed"}, + {"value": "1_0.5", "kind": "malformed"}, + {"value": "\uff14", "kind": "malformed"}, + {"value": "\u0663", "kind": "malformed"}, + {"value": "1\u00b2", "kind": "malformed"}, + {"value": "\u00bd", "kind": "malformed"}, + {"value": "0", "kind": "malformed"}, + {"value": "-5", "kind": "malformed"}, + {"value": "inf", "kind": "malformed"}, + {"value": "-inf", "kind": "malformed"}, + {"value": "nan", "kind": "malformed"}, + {"value": "Infinity", "kind": "malformed"}, + {"value": "abc", "kind": "malformed"}, + {"value": "5s", "kind": "malformed"}, + {"value": "5 5", "kind": "malformed"}, + {"value": "1,5", "kind": "malformed"}, + + {"value": "", "kind": "unset"}, + {"value": " ", "kind": "unset"} + ] +} diff --git a/tests/test_daemon_start_diagnostics.py b/tests/test_daemon_start_diagnostics.py index d9e30de1..e2c1a8f0 100644 --- a/tests/test_daemon_start_diagnostics.py +++ b/tests/test_daemon_start_diagnostics.py @@ -320,6 +320,68 @@ def test_a_window_too_short_for_one_poll_still_polls_once(monkeypatch): assert dm._start_window_attempts() == 1 +# ── the shape, against the one list both entry points read ────────────────── + +SHAPE_FIXTURE = Path(__file__).resolve().parent / "data" / "start_window_shapes.json" + + +class _Warnings(logging.Handler): + """What the resolver logged. Attached to its logger rather than read through + `caplog`: this asserts on what the module said, not on what the root logger + happened to keep, so a conftest that reconfigures logging cannot quiet it.""" + + def __init__(self): + super().__init__() + self.messages: list[str] = [] + + def emit(self, record): + self.messages.append(record.getMessage()) + + +def test_every_shape_in_the_shared_list_means_what_it_says(monkeypatch): + """The client half of `tests/data/start_window_shapes.json`. + + The same file is read by the GUI's suite (`emrg/gui/test/daemon_client.test.js`), so a + value the two resolvers disagree about cannot pass on one side only. That is the claim + #1404 makes — "the GUI honours the start window the TUI honours" — and it was untested + before this list existed: measured on #1404's head, `float()` and `Number()` disagreed on + 7 of these values, and `EMRG_START_TIMEOUT=0x10` gave the GUI a 16 s window while the + client fell back to 4.5 s. The fixture is the authority here, not this docstring. + + The malformed half is the load-bearing one (`1_000` is a thousand seconds to `float()`, + `4` is four), so its size is asserted too: a list that quietly lost it would still pass. + """ + import json + + cases = json.loads(SHAPE_FIXTURE.read_text(encoding="utf-8"))["values"] + malformed = [c for c in cases if c["kind"] == "malformed"] + assert len(cases) >= 25, f"the shared list shrank: {len(cases)} values" + assert len(malformed) >= 10, f"the malformed half shrank: {len(malformed)} values" + + warnings = _Warnings() + dm.logger.addHandler(warnings) + try: + for case in cases: + raw, kind = case["value"], case["kind"] + warnings.messages.clear() + monkeypatch.setenv(dm._START_WINDOW_ENV, raw) + seconds = dm._start_window_seconds() + warned = any(dm._START_WINDOW_ENV in m for m in warnings.messages) + + if kind == "duration": + assert seconds == pytest.approx(case["seconds"]), raw + assert not warned, f"{raw!r} is a duration and must not warn" + assert dm._start_window_attempts() >= 1, raw + elif kind == "malformed": + assert seconds == dm._START_WINDOW_DEFAULT_SECONDS, f"{raw!r} must fall back" + assert warned, f"{raw!r} must warn — a silent fallback hides the typo" + else: # "unset" + assert seconds == dm._START_WINDOW_DEFAULT_SECONDS, raw + assert not warned, f"{raw!r} is how a shell says unset; it must not warn" + finally: + dm.logger.removeHandler(warnings) + + # ── a stop is not a crash ─────────────────────────────────────────────────── From dd83cb1e2d12d4e1c89f56217f494dd6ac8c268b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 06:58:51 +0800 Subject: [PATCH 3/3] emrg: the start-window section describes both entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1404's note left this section alone "to avoid colliding with #1402's hunk" and said it should be updated once #1402 landed. #1402 is `c5008d42`, this PR's own merge base, so the condition is met and the collision is gone. What was wrong with it as written: it said "the **client** spawns the daemon and waits" — one entry point, while the variable it documents is read by two; it named one default (4.5 s, 15 polls) where the GUI's is 5.0 s; and it said the report "names the bound it really waited ... because the window is quantised to the 0.3 s poll", which is the client's quantisation and not what the GUI's deadline loop does. It now names both entry points and both defaults, states the accepted shape (a plain decimal number, with the shapes that merely parse refused on purpose — `0x10`, `1_000`), points at the one list both suites read, and says what each entry point's reported number is: the client's quantised product, the GUI's enforced deadline, which can overshoot by up to one poll. --- DEVELOPMENT.md | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bbe58ccd..5caa717d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -454,30 +454,44 @@ Check `emrg -v` and when the daemon started before re-reading the source. ### `emrgd failed to start within N s` -The client spawns the daemon and waits for it to accept connections. When that -wait runs out, the error carries what is known about the attempt: whether the -child is still running, its exit code if it is not, the daemon log lines **this** -attempt appended, and the contents of `~/.emrg/emrgd-start.err` (the child's own -stderr, which is where a failure before logging starts can be read at all). - -The window defaults to **4.5 s** (15 polls, 0.3 s apart). A start that is merely -slower than that on a cold machine is worth raising it for; set the variable on -the command that starts the client, since the client is what waits: +Whichever entry point you started spawns the daemon and waits for it to accept +connections. When that wait runs out, the error carries what is known about the +attempt: whether the child is still running, its exit code if it is not, the +daemon log lines **this** attempt appended, and the contents of +`~/.emrg/emrgd-start.err` (the child's own stderr, which is where a failure +before logging starts can be read at all). + +**Both entry points read the same variable**, `EMRG_START_TIMEOUT`, in seconds: +the terminal client (`emrg`, `emrg/client/daemon_manager.py`) and the GUI +(`emrg/gui/daemon_client.js`). Their defaults differ because the GUI's was +already its own — **4.5 s** (15 polls, 0.3 s apart) for the client, **5.0 s** for +the GUI — and neither changes; what the variable adds is the ability to raise +either one. Set it on a client (for the terminal) or in the environment the GUI +is launched from: ```bash EMRG_START_TIMEOUT=30 emrg ``` -It takes seconds as a number. A value that is not a positive, finite number is -ignored, with a warning in the client log — a typo in a tuning variable must -never be the reason a start fails, so it falls back to the default rather than -raising. +A value is **a plain decimal number of seconds** — digits, an optional sign, an +optional fraction and exponent (`30`, `4.5`, `.5`, `1e2`). Anything else is +ignored with a warning in that entry point's own log and the default is used: a +typo in a tuning variable must never be the reason a start fails. The refusal is +deliberate about the shapes that merely *parse*: `0x10`, `1_000` and full-width +or Arabic-Indic digits are all refused, so the same value cannot mean 16 seconds +to one entry point and 4.5 to the other (`1_000` would be a sixteen-minute wait +if it were accepted). Both entry points read one shared list of allowed values in +their tests — `tests/data/start_window_shapes.json` — so a divergence in either +resolver is a test failure rather than a surprise. Two facts make the window cheap to raise: a child that has **exited** is reported on the first poll with its exit code, so the window only ever bounds a child that -is alive but not yet listening; and the failure report names the bound it really -waited, not the one you asked for, because the window is quantised to the 0.3 s -poll. +is alive but not yet listening; and the reported bound is one the loop really +applied, with a floor of one poll on both sides. The two report it differently, +and both are honest: the client quantises the window to whole polls and names +that product (`1.0` → `0.9s`), while the GUI's loop is deadline-based, names the +deadline it enforced and may therefore overshoot it by up to one poll (`1.0` → +`1.0s` reported, ~1.2 s actually waited). ### Why is a write blocked at `workspace-write`?