Sfoglia il codice sorgente

fix(web-app): defer to a user-configured client-hmr row

The whole-tree name scan (entries() recurses into subtrees) already
skips creation when any patch layer carries the row, including a
disabled one; make that contract explicit in the comment, pin it with a
test, and record it in the Agent Note.
Turtle 1 mese fa
parent
commit
fb301ace65

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md
-2026-08-11-cmdline-seam-trim.md: e9d30c94baed0e0e76c36d7561f50353a4b3eace
-2026-08-11-cmdline-seam-trim.zh.md: c4ee26d25b72b4d77d6ec2affbb4647e08c7cb1a
+2026-08-11-cmdline-seam-trim.md: 3fb2f3e0941ad4cf6e2fb7e1afbe6cf31af92f41
+2026-08-11-cmdline-seam-trim.zh.md: 002a76fc57e1d26e15c2619a380874a0eecd435f

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md

@@ -12,7 +12,7 @@ The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shippe
 
 Express all three with interfaces that already exist:
 
-- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`, guarded for reload idempotence. A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted.
+- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`; a whole-tree name scan makes the creation reload-idempotent and defers to a user-configured `dsh-client-hmr` row (even a disabled one). A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted.
 - **Tree-carrier config.** Include declares the existing `EntryGroup.key` marker instead of implementing `EntryConfigResolver`; the Loader hook keeps every tree carrier's config literal. Include's own `path` loses `!!js` support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead.
 - **Launcher app-knowledge.** The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through `ctx.appExit` like any other app; its output streams are a package-internal `internals` test seam, and `ctx.headlessIo` is deleted.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 三者全部改用已经存在的接口表达:
 
-- **条件 dev 行。** `dsh-web-app` 不再随附禁用的 `client-hmr` 行;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行,并带重载幂等保护。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。
+- **条件 dev 行。** `dsh-web-app` 不再随附禁用的 `client-hmr` 行;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行;全树名称扫描让创建具备重载幂等性,并让位于用户自行配置的 `dsh-client-hmr` 行(即便该行被禁用)。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。
 - **树载体配置。** Include 改为声明已有的 `EntryGroup.key` 标记,不再实现 `EntryConfigResolver`;Loader 钩子让每个树载体的配置保持字面值。Include 自己的 `path` 失去 `!!js` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。
 - **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出(SIGINT 仍为 130);启动器无从知道应用是否认为工作已完成,而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。
 

+ 5 - 2
packages/bundle/web-app/src/index.ts

@@ -160,8 +160,11 @@ export function apply(ctx: Context, config: Config): void {
     } else {
       void loader.await().then(async () => {
         // The tree can be disposed while settlement was in flight (early
-        // SIGTERM); re-check before mutating it. A reload of this fiber must
-        // not duplicate the row a previous generation created.
+        // SIGTERM); re-check before mutating it. The name scan spans every
+        // tree (entries() recurses into subtrees), so a row the user
+        // configured in a patch layer — enabled, reconfigured, or
+        // deliberately disabled — wins over this default, and a reload of
+        // this fiber never duplicates the row a previous generation created.
         if (ctx.get('loader') === undefined) return
         const mounted = [...ctx.loader.entries()].some(entry => entry.options.name === HMR_ROW_NAME)
         if (!mounted) await ctx.loader.create({ name: HMR_ROW_NAME })

+ 22 - 0
packages/bundle/web-app/tests/web-app.spec.ts

@@ -187,6 +187,28 @@ describe('web-app runtime glue', () => {
     await ctx.fiber.dispose()
   })
 
+  it('defers to a user-configured client-hmr row anywhere in the tree', async () => {
+    stageDist()
+    const ctx = new Context()
+    ctx.provide('httpServer', fakeHttpServer().server)
+    const created: string[] = []
+    // The user's own row — possibly patched into an include subtree and even
+    // disabled there — already carries the name; the runtime must not create
+    // a second one beside it.
+    ctx.provide('loader', {
+      entries: () => [{ options: { id: 'my-hmr', name: '@deepseek-ai/dsh-client-hmr', disabled: true } }][Symbol.iterator](),
+      create: (options: { name: string }) => {
+        created.push(options.name)
+        return Promise.resolve(options.name)
+      },
+      await: () => Promise.resolve(),
+    } as never)
+    apply(ctx, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(created).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
   it('skips the dev row when the tree is disposed during settlement and logs a creation failure', async () => {
     stageDist()
     const raced = new Context()