Replies: 1 comment
|
根因已定位并单独发帖:#6391 引入提交:19444907「feat: electron 打包」(2026-08-28) 把 ClientModuleRegistry 的 static inject 从 ['webServer','loader'] 改成 ['loader']。旧写法事实上是一道「等 webServer 就绪(≈激活波次结束)才构造」的延迟闸门,所以只 compose 一次;首次进入发布的版本是 0.1.5-alpha.1。 另外 #6081 提出的「给 Web bundle 的 modules 行补回 webServer 激活边」很可能同时修掉这个启动耗时(恢复延迟构造),值得一起评估。 边界补充(#6391 讨论后加): 上面提到的 #6081 那条激活边只在 Web 组合内成立 —— 桌面宿主没有 |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
环境
0.1.5-rc.1(npm 全局安装)、node v26.7.0、Windowsweb(安装目录:<npm-global>/node_modules/@deepseek-ai/dsh)现象
dsh web从执行到打印dsh web: http://127.0.0.1:...需要 17–20 s(热缓存);重启后首次(冷缓存)实测 34 s / 56 s。根因:
@deepseek-ai/dsh-client-modules一次启动把组合包重建了 8 次用
NODE_OPTIONS注入预载模块统计compose(),实测一次启动执行 8 次(t≈4.9 / 5.0 / 6.4 / 7.8 / 9.7 / 12.4 / 14.6 / 16.9 s):compose()一次,随后flush()再组合一次;queueMicrotaskflush,脏集每变化一次就全量重建。每次
compose()的输入与代价:client.js共 10.66 MB;其中dsh-client-ui-sidebar-documentpreview/lib/client.js单文件 6.89 MB(内联 PDF.js,占 65%);.map,因此每个包都走identitySectionMap():newlineCount()用for (const char of value)逐字符遍历(合计 129,526 行),再用Array.from({ length }).join(';')生成逐行 mappings,并把整份源码放进sourcesContent(生成的 map JSON 约 12 MB);buildCombo([record])一次(又处理约 22.7 MB),而浏览器正常启动只请求批次 URL;buildCombo()先把 10.7 MB 拼成一个大字符串再Buffer.from()。CPU profile(19.3 s 采样窗口)自耗时:
buildCombonewlineCountidentitySectionMaputf8Write(大字符串 → Buffer)对照:把同等工作量在同一台机器上单独跑一遍只要 0.93 s —— 这 9.3 s 主要是重复劳动,不是机器慢。
复现与测量方法
统计
compose()次数:用NODE_OPTIONS注入一个data:text/javascript预载模块,通过module.registerHooks()的loadhook 重写dsh-client-modules/lib/index.js,把compose() {替换为带计数器与时间戳的版本:定位耗时:
node --cpu-prof --cpu-prof-dir <dir> .../lib/bin.js web --no-open --port 0,再把.cpuprofile按 callFrame 聚合自耗时。端到端对照:切换新旧实现各启动一次,抓取
/plugins/??...&rev=...批次 URL 与单包 URL(含.map)的 sha1 与rev。建议的修复(已在本地验证)
newlineCount()改用indexOf('\n', i + 1)循环;identitySectionMap()的 mappings 用';AACA'.repeat(lines - 1)生成,去掉Array.from;compose()不再对每个 record 立即buildCombo,改为首次请求该 URL 时按 rev 记忆化生成(新增recordArtifact(),响应体改为惰性build());compose()一次,internal/plugin的延迟 flush 由微任务改为宏任务合并,一次激活突发只重算一次(8 → 2);buildCombo()用Buffer.concat组装脚本,避免 10.7 MB 字符串中转。实测(同一台机器,热缓存):
compose()次数.map字节验证:
packages/client/modules测试 82/82 通过(含新增的「一次激活突发只重算一次」回归测试)、tsc -b通过、oxlint0 警告 0 错误、tsdown打包成功。第 4 点改变了 flush 的粒度,原来的microtask契约与 README/注释已同步更新,并调整了一个等待微任务的既有测试。完整 diff(基于 master @ aa8262e,可直接 apply)
```diff
diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md
index dad3cd2..b39d65c 100644
--- a/packages/client/modules/README.md
+++ b/packages/client/modules/README.md
@@ -65,7 +65,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
Incremental composition
-The Node half scans incrementally per package — no full-rescan path. Every
internal/pluginemission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only throughrebuilt()(the HMR hook).+The Node half scans incrementally per package — no full-rescan path. Every
internal/pluginemission marks the fiber's entry name dirty; a coalesced flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set, scans it synchronously, and composes once, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only throughrebuilt()(the HMR hook).The Node half snapshots each client bundle and available source map before publication. It groups resources into
/plugins/??...&rev=...combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. Every combo map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces, so startup does not hash every plugin; HMR hashes only an artifact reported as changed. Advertised responses are immutable, and an unknown combination or revision returns 404.diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md
index ef7a01d..fef107d 100644
--- a/packages/client/modules/README.zh.md
+++ b/packages/client/modules/README.zh.md
@@ -65,7 +65,7 @@ application combo 脚本在启动时仅注册一次插件 factory;模块主体
增量组合
-Node 半侧逐包增量扫描——没有全量重扫路径。每次发出
internal/plugin事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过rebuilt()(HMR 钩子)进入图。+Node 半侧逐包增量扫描——没有全量重扫路径。每次发出
internal/plugin事件时,系统都会把该 fiber 的 entry 名标脏;合并后的 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步扫描、随后只组合一次,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过rebuilt()(HMR 钩子)进入图。Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map。它把资源分组到
/plugins/??...&rev=...combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,所以启动时不哈希每个插件;HMR 只哈希被报告为已变化的产物。已公告响应不可变;未知组合或 revision 返回 404。diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts
index c823657..9e1e7a8 100644
--- a/packages/client/modules/src/index.ts
+++ b/packages/client/modules/src/index.ts
@@ -11,15 +11,15 @@
*
internal/pluginemission (fiber construction/disposal) marks*/
@@ -296,8 +296,10 @@ function comboSource(record: WebPluginRecord): ComboSource {
}
/** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
-function comboScript(input: string, sourceMapUrl?: string): Buffer {
${input}//# sourceMappingURL=${sourceMapUrl}\n)+function comboScript(input: string | Buffer, sourceMapUrl?: string): Buffer {
//# sourceMappingURL=${sourceMapUrl}\n)])}
/** Parse an optional source-map artifact; missing maps do not prevent plugin execution. /
@@ -328,7 +330,11 @@ function sourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
/* Count generated lines while assembling indexed-map section offsets. */
function newlineCount(value: string): number {
let count = 0
return count
}
@@ -354,8 +360,8 @@ function comboSectionMap(record: WebPluginRecord): Record<string, unknown> {
/** Map each generated line to the same line in a bundled JavaScript source. */
function identitySectionMap(source: string, sourceUrl: string): Record<string, unknown> {
AAAA${';AACA'.repeat(lines - 1)}return {
version: 3,
names: [],
@@ -367,7 +373,7 @@ function identitySectionMap(source: string, sourceUrl: string): Record<string, u
/** Concatenate one or more factory registrations and compose their maps as indexed sections. */
function buildCombo(records: readonly WebPluginRecord[], revision?: string): ComboArtifact {
const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
let line = 0
for (const record of records) {
@@ -377,16 +383,16 @@ function buildCombo(records: readonly WebPluginRecord[], revision?: string): Com
: comboSectionMap(record)
sections.push({ offset: { line, column: 0 }, map: section })
const bundle =
${prepared.source};\nline += newlineCount(bundle)
}
const sourceMap = Buffer.from(
${JSON.stringify({ version: 3, file: 'client.js', sections })}\n)const rev = revision ?? framedHash('combo', [sourceBytes, sourceMap])
const entries = records.map(record => record.entry.id)
const url = comboUrl(entries, rev)
const sourceMapUrl = comboUrl(entries, rev, true)
}
/** Add initial-load scheduling metadata to a combo artifact. */
@@ -515,6 +521,16 @@ window.ModuleLoader={
/
+/*
/pluginsresponse. A batch combo is materialized during+type BundleResponse =
export class ClientModuleRegistry extends Service {
static inject = ['loader']
@@ -528,10 +544,12 @@ export class ClientModuleRegistry extends Service {
private readonly dirty = new Set()
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
private nextInitialRevision = 0
/** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
private flushQueued = false
private composed: WebBootGraph
@@ -550,7 +568,9 @@ export class ClientModuleRegistry extends Service {
this.dirty.add(entryName)
if (this.flushQueued) return
this.flushQueued = true
@@ -560,12 +580,14 @@ export class ClientModuleRegistry extends Service {
// current entries, flushed synchronously (nothing async between subscribe,
// seed, and flush).
for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
const failures: Error[] = []
// Validation stays synchronous (a broken package must still fail
// activation); composition runs once, over the whole seeded set.
this.flush(err => failures.push(err), true)
if (failures.length > 0) {
throw new ClientPackageCompositionError(failures)
}
this.composed = this.compose()
const registerWebCarrier = (webCtx: Context): void => {
webCtx.effect(
@@ -709,15 +731,16 @@ export class ClientModuleRegistry extends Service {
contentType: 'application/json; charset=utf-8',
})
}
for (const record of this.table.values()) {
@@ -976,7 +999,12 @@ export class ClientModuleRegistry extends Service {
return true
}
let changed = false
for (const entryName of [...this.dirty]) {
this.dirty.delete(entryName)
@@ -988,7 +1016,7 @@ export class ClientModuleRegistry extends Service {
onError(error instanceof Error ? error : new Error(String(error)))
}
}
if (!changed || validateOnly) return
let composed: WebBootGraph
try {
composed = this.compose()
@@ -1004,6 +1032,22 @@ export class ClientModuleRegistry extends Service {
this.notifyGraphChanged()
}
/**
*/
private recordArtifact(record: WebPluginRecord, which: 'script' | 'map'): Buffer {
const key =
${record.entry.id}\u0000${record.entry.rev}let artifact = this.recordArtifacts.get(key)
if (artifact === undefined) {
}
return which === 'map' ? artifact.sourceMap : artifact.script
}
private bundleResource(method: string | undefined, url: string): {
status: number
headers?: Record<string, string>
@@ -1014,10 +1058,11 @@ export class ClientModuleRegistry extends Service {
const resourceUrl =
${requestUrl.pathname}${requestUrl.search}const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl)
if (response !== undefined) {
// Anything else under /plugins (including unadvertised combinations and
diff --git a/packages/client/modules/src/invariant.ts b/packages/client/modules/src/invariant.ts
index 00e4e71..aac2072 100644
--- a/packages/client/modules/src/invariant.ts
+++ b/packages/client/modules/src/invariant.ts
@@ -21,7 +21,7 @@ export const inject = ['invariants']
own coalesced flush.
*/
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts
index 4f06360..82fccea 100644
--- a/packages/client/modules/tests/node-half.client.spec.ts
+++ b/packages/client/modules/tests/node-half.client.spec.ts
@@ -378,7 +378,7 @@ describe('client bundle activation', () => {
entries.push(alias)
emitLoaderEntryChange(context, alias)
await new Promise(resolve => setImmediate(resolve))
expect(warning).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining(
package ${packageName} resolves from multiple active Loader sources) as string,}))
@@ -386,12 +386,34 @@ describe('client bundle activation', () => {
entries.splice(entries.indexOf(packageName), 1)
emitLoaderEntryChange(context, packageName)
await new Promise(resolve => setImmediate(resolve))
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
expect(service.graph().entries[0]!.rev).not.toBe(firstRevision)
expect(service.clientPath(packageName)).toBe(clientPath)
})
it('coalesces a burst of activation events into one recomposition', async () => {
const packageNames = ['@fixture/burst-a', '@fixture/burst-b', '@fixture/burst-c']
for (const packageName of packageNames) writeBuiltPackage(packageName, {})
// The generator closes over the array, so the burst arrives after construction.
const entries: string[] = []
const { context, service } = constructWithRoute(entries)
const notifications: string[][] = []
service.onGraphChanged(() => {
})
// One emission per arriving plugin: the activation pattern this coalescing
// exists for. Every arrival recomposes before the change, one flush does.
for (const packageName of packageNames) {
}
await new Promise(resolve => setImmediate(resolve))
expect(notifications).toEqual([[...packageNames].sort()])
})
it('uses owning-tree package resolution for an import-only Worker module loader', () => {
const packageName = '@fixture/worker-loader'
writeBuiltPackage(packageName, {})
```
All reactions