fix(cordis): make config reload transactional
This commit is contained in:
@@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
// nothing is left to unmount or await then.
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) return
|
||||
const fiber = entry.fiber
|
||||
ctx.loader.remove(id)
|
||||
// remove() only starts the fiber's dispose; join it so the chooser's
|
||||
// unload signals completion only after the backend quiesced.
|
||||
await fiber?.dispose()
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after the backend quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('real Loader composition', () => {
|
||||
const { ctx, configPath } = await loadComposition('127.0.0.1')
|
||||
|
||||
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||
ctx.loader.remove(backendEntry.id)
|
||||
await ctx.loader.remove(backendEntry.id)
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
|
||||
@@ -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 packages/host/webserver/README.md
|
||||
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
|
||||
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a
|
||||
README.md: c3c7b222683bc7731a6c21f2fffd325225099bab
|
||||
README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db
|
||||
|
||||
@@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf
|
||||
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
|
||||
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context, FiberState } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '../src/index.ts'
|
||||
@@ -142,25 +142,17 @@ describe('real Loader composition', () => {
|
||||
const firstRoot = root
|
||||
root = undefined // keep the first composition's files until the end
|
||||
|
||||
// loader.await() never rejects (allSettled); the bind failure surfaces as
|
||||
// a FAILED fiber whose error escapes as a late rejection — the shape the
|
||||
// boot's installFailLoud is contracted to catch. Capture it here the same
|
||||
// way, and assert it really is the bind error.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
let second: Context | undefined
|
||||
try {
|
||||
second = await loadComposition(takenPort)
|
||||
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
|
||||
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
|
||||
// The rejection escapes a tick after loader.await() settles; bounded poll.
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
let failure: unknown
|
||||
try {
|
||||
await loadComposition(takenPort)
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
|
||||
second = context
|
||||
expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
await second?.fiber.dispose()
|
||||
context = first
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
|
||||
Reference in New Issue
Block a user