modelstage 0.0.0-stage → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// plugin-store.mjs —— 解包产物的落盘缓存(17b 第二步:指纹 / 原子写 / LRU / 并发去重 / stats)
|
|
2
|
+
//
|
|
3
|
+
// 目录布局(与方案一致):
|
|
4
|
+
// data/unpacked/<fingerprint>/index.json 元信息 + 文件清单 + 源文件 stat 快照
|
|
5
|
+
// data/unpacked/<fingerprint>/files/<relPath> 插件产物原样落盘
|
|
6
|
+
//
|
|
7
|
+
// 四条设计要点:
|
|
8
|
+
// 1. **指纹用 stat 不用内容 hash**:42MB 每次 sha256 要 ~200ms,而 mtime/size 一变就该弃用;
|
|
9
|
+
// 指纹里还含 pluginId + pluginVersion(插件升级后产物可能变,必须自然失效)。
|
|
10
|
+
// index.json 里另存 source 的 size/mtimeMs,读缓存时**再 stat 比对一次**(防指纹碰撞/手工改名)。
|
|
11
|
+
// 2. **原子写**:先落到 `data/unpacked/.tmp-<fp>-<pid>-<rand>/`,全部成功再 rename 成 <fp>;
|
|
12
|
+
// 中途失败删 tmp。绝不出现"半个目录被当成命中"。
|
|
13
|
+
// 3. **并发**:同一 fp 的并发请求共用同一个 Promise(in-flight 去重)+ 全局串行队列(解包吃满一核,
|
|
14
|
+
// 串行避免多核抢渲染——首次加载时页面还在渲染,实测单份解包已把 12fps 压到 8fps)。
|
|
15
|
+
// 4. **清理直接删,不进 .trash**:这是**可重建缓存**不是用户数据;进回收站等于占双份空间。
|
|
16
|
+
// 安全删除纪律针对不可重建的数据,这里破例,理由写在此处以免后人以为是漏了。
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { runnerInfo, providerFingerprint } from './plugin-runner.mjs';
|
|
21
|
+
import { checkUnpackMemory } from './capacity.mjs';
|
|
22
|
+
import { estimateMemoryOf } from './plugin-runner.mjs';
|
|
23
|
+
// 解包(含 vm 沙盒装载、原子落盘、probe 写入)整段跑在 worker 里 —— 主线程只做准入与索引维护。
|
|
24
|
+
// 这四个模块是"单一实现":worker 与主线程共用,不在两边各写一份。
|
|
25
|
+
import { createProbeStore } from './probe-store.mjs';
|
|
26
|
+
import { rssNow } from './mem-probe.mjs';
|
|
27
|
+
import { createUnpackWorkerClient } from './worker-client.mjs';
|
|
28
|
+
// 进度档白名单的**唯一来源**(档名 + 对应的 phases 字段名都在那张表里):
|
|
29
|
+
// SSE 补发循环从它派生,本文件不再手写任何档名字面量。
|
|
30
|
+
import { PROGRESS_PHASES } from './runtime/exec.mjs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* SSE 补发用的「档名 → phases 字段名」(从 PROGRESS_PHASES 派生)。
|
|
34
|
+
* 导出是为了让单测能断言"补发的档名集合 === 表的档名集合" —— 新增档位漏配 msField 时,
|
|
35
|
+
* 这里会少一项、测试立刻红,而不是运行时静默少发一条进度。
|
|
36
|
+
*/
|
|
37
|
+
export const SSE_PHASE_FIELDS = Object.freeze(PROGRESS_PHASES
|
|
38
|
+
.filter((p) => typeof p.msField === 'string' && p.msField)
|
|
39
|
+
.map((p) => ({ name: p.name, msField: p.msField })));
|
|
40
|
+
|
|
41
|
+
/** 1MB 的字节数(下面多处换算用;写成常量避免 1048576 散落) */
|
|
42
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/** 缓存上限(可用 config 覆盖;4GB 可用内存 / 磁盘都在这个量级以内安全) */
|
|
45
|
+
const DEFAULT_LIMITS = { maxDirs: 20, maxBytes: 8 * 1024 * 1024 * 1024 };
|
|
46
|
+
|
|
47
|
+
/** 清理解包的节流间隔:resolve 后最多 10 分钟扫一次目录 */
|
|
48
|
+
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/** 读单个插件目录的版本号(读不到就用 0,不阻塞) */
|
|
51
|
+
function versionOfPluginDir(dir) {
|
|
52
|
+
try {
|
|
53
|
+
const text = fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8');
|
|
54
|
+
const m = /"version"\s*:\s*("?)([^",\s]+)\1/.exec(text);
|
|
55
|
+
return m ? m[2] : '0';
|
|
56
|
+
} catch {
|
|
57
|
+
return '0';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 指纹里的 pluginVersion。
|
|
63
|
+
*
|
|
64
|
+
* 为什么可能是"插件根"而不是单个插件目录:解包插件不止一个(live2d / spine 各一个,
|
|
65
|
+
* 都声明 provides:['asset-provider'])。若只读某一个插件的版本,另一个升级时缓存不会失效,
|
|
66
|
+
* 表现是"改了插件但产物还是旧的"——比崩溃更隐蔽。
|
|
67
|
+
* 所以传插件根时把每个子目录的版本汇总成一个串:任一个升级都会让整串变化。
|
|
68
|
+
* @param {string} dir 插件目录,或含多个插件的插件根
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function pluginVersion(dir) {
|
|
72
|
+
// 自身就是插件目录(有 plugin.json)⇒ 单个版本,保持既有行为
|
|
73
|
+
if (fs.existsSync(path.join(dir, 'plugin.json'))) return versionOfPluginDir(dir);
|
|
74
|
+
try {
|
|
75
|
+
return fs
|
|
76
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
77
|
+
.filter((e) => e.isDirectory())
|
|
78
|
+
.map((e) => `${e.name}@${versionOfPluginDir(path.join(dir, e.name))}`)
|
|
79
|
+
.sort()
|
|
80
|
+
.join('+') || '0';
|
|
81
|
+
} catch {
|
|
82
|
+
return '0';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 递归统计目录占用(清理时用) */
|
|
87
|
+
function dirBytes(dir) {
|
|
88
|
+
let total = 0;
|
|
89
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
90
|
+
const p = path.join(dir, e.name);
|
|
91
|
+
if (e.isDirectory()) total += dirBytes(p);
|
|
92
|
+
else {
|
|
93
|
+
try { total += fs.statSync(p).size; } catch { /* 并发删除中,忽略 */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 创建解包缓存。
|
|
101
|
+
* @param {{rootDir: string, pluginDir: string, enqueue?: Function, limits?: object}} opts
|
|
102
|
+
* enqueue:外部串行队列(serve.mjs 传 backgrounds 的 enqueue,复用同一把锁);不传则内部自建
|
|
103
|
+
*/
|
|
104
|
+
/**
|
|
105
|
+
* 安全地问一次「插件声明的峰值」——**任何异常都必须被吞掉**。
|
|
106
|
+
*
|
|
107
|
+
* 为什么必须有这个兜底:这一步跑在**准入热路径**上(每次缓存未带声明时都会走到)。
|
|
108
|
+
* 插件可能被卸载、被开关关掉、版本不匹配、blocksSummary 在中途抛错……
|
|
109
|
+
* 准入判断挂掉比"估得不准"严重得多:前者是**整个加载流程直接失败**,后者只是这一次被拒/放行。
|
|
110
|
+
* ⇒ 问不到就返回 null,让调用方退回缓存里的值(再退就到 legacy 的源包大小粗估)。
|
|
111
|
+
*
|
|
112
|
+
* 为什么是"现场问"而不是"把 peakBytes 持久化进 probe.json":
|
|
113
|
+
* 插件峰值表会随版本更新(标定口径、样本都会变),持久化的 peakBytes 会悄悄变陈旧;
|
|
114
|
+
* 而一旦陈旧又没有任何机制去失效它 —— 那就成了"同一事实的第二处表达",正是项目里反复踩的漂移坑。
|
|
115
|
+
* 现场问的代价是只读块表(P90 < 1ms,实测 P50 0.13ms),完全可接受,
|
|
116
|
+
* 换来的是"声明值永远等于插件当前表"。⇒ 不要"顺手优化成缓存下来",那会把安全修变回不安全。
|
|
117
|
+
*
|
|
118
|
+
* @param {object} req {rootId, rel, absPath}
|
|
119
|
+
* @param {Function} [askFn] 注入点(默认 estimateMemoryOf;单测用它注入会抛错的实现)
|
|
120
|
+
* @returns {object|null} 插件声明;问不到/抛错都返回 null
|
|
121
|
+
*/
|
|
122
|
+
export function askDeclaredSafely(req, askFn = estimateMemoryOf) {
|
|
123
|
+
try {
|
|
124
|
+
const declared = askFn(req);
|
|
125
|
+
return declared && declared.decompressedBytes > 0 ? declared : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null; // 插件不可用/卸载/解析抛错 ⇒ 兜底,绝不向上抛
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 取一份 index.json 里的 rel(VFS 相对路径),统一归一化成 `/` 分隔。
|
|
133
|
+
* 单独抽出来是因为有两个用处:算期望 prefix(取 dirname)和写日志(原样展示)——
|
|
134
|
+
* 两处必须看到同一个 rel,否则日志里出现反斜杠会让人以为是另一份缓存。
|
|
135
|
+
*
|
|
136
|
+
* @param {{source?: {rel?: string}}|null} idx index.json 的内容
|
|
137
|
+
* @returns {string} 归一化后的 rel(缺失时为空串)
|
|
138
|
+
*/
|
|
139
|
+
function relOf(idx) {
|
|
140
|
+
const rel = (idx && idx.source && idx.source.rel) || '';
|
|
141
|
+
return String(rel).replace(/\\/g, '/');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 由 index.json 自己记录的 pluginId / source 反推「prefix 应该长什么样」。
|
|
146
|
+
*
|
|
147
|
+
* 契约 §1.2:prefix = `vfs://<providerId>/<rootId>/<相对目录>/`,
|
|
148
|
+
* 相对目录取 rel 的 dirname(rel 指向容器文件本身,prefix 以该文件所在目录为命名空间)。
|
|
149
|
+
* 实测 24 份现存缓存 100% 符合(三种形态都在内:无目录 / `live2d` / `live2d_<HASH>`),
|
|
150
|
+
* 所以这里做**精确比对**,不用"以 /live2d/ 结尾"那种猜法 —— 那个对多层 rel 会误判。
|
|
151
|
+
*
|
|
152
|
+
* @param {{pluginId?: string, source?: {rootId?: string, rel?: string}}} idx index.json 的内容
|
|
153
|
+
* @returns {string} 期望的 prefix(含结尾 /)
|
|
154
|
+
*/
|
|
155
|
+
export function expectedPrefix(idx) {
|
|
156
|
+
const pluginId = (idx && idx.pluginId) || '';
|
|
157
|
+
const rootId = (idx && idx.source && idx.source.rootId) || '';
|
|
158
|
+
const dir = path.posix.dirname(relOf(idx)); // 'aijier_3' → '.' | 'live2d/x' → 'live2d'
|
|
159
|
+
const dirPart = dir && dir !== '.' ? `${dir}/` : '';
|
|
160
|
+
return `vfs://${pluginId}/${rootId}/${dirPart}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 缓存里的 prefix 是否与「当前该有的形态」不符;不符返回明细,一致返回 null。
|
|
165
|
+
*
|
|
166
|
+
* 抽成纯函数(不读盘、不打日志)是为了能被单测直接钉住 ——
|
|
167
|
+
* 走 peek() 验证要凑齐指纹(含 pluginVersion)才能命中,太脆;
|
|
168
|
+
* 判据本身才是要锁的东西。
|
|
169
|
+
*
|
|
170
|
+
* @param {{pluginId?: string, prefix?: string, source?: object}} idx index.json 的内容
|
|
171
|
+
* @returns {{cached: string, expect: string, rel: string}|null}
|
|
172
|
+
*/
|
|
173
|
+
export function prefixMismatch(idx) {
|
|
174
|
+
const cached = idx && idx.prefix;
|
|
175
|
+
if (typeof cached !== 'string') return null;
|
|
176
|
+
const expect = expectedPrefix(idx);
|
|
177
|
+
if (cached === expect) return null;
|
|
178
|
+
// rel 与 expectedPrefix 用同一份归一化结果(日志与判据看到同一个 rel)
|
|
179
|
+
return { cached, expect, rel: relOf(idx) || '?' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 缓存命中时校验 prefix 是否还是「当前该有的形态」,不符就打一条 warn。
|
|
184
|
+
*
|
|
185
|
+
* **只告警、不改行为**:自动失效会把用户已经解好的包重新解一遍
|
|
186
|
+
* (实测单包 9~15 秒),代价远大于多几次 HTTP 往返。
|
|
187
|
+
* 先让「整批 prefix 陈旧」这件事可见,等看到真实频率再决定要不要失效。
|
|
188
|
+
* 命中路径的返回字段 / 状态码 / 耗时都不变;prefix 与预期一致的正常缓存不产生任何日志。
|
|
189
|
+
*
|
|
190
|
+
* @param {string} fp 指纹(日志里用来定位是哪一份缓存)
|
|
191
|
+
* @param {object} idx 命中的 index.json
|
|
192
|
+
*/
|
|
193
|
+
function warnIfPrefixStale(fp, idx) {
|
|
194
|
+
const bad = prefixMismatch(idx);
|
|
195
|
+
if (!bad) return;
|
|
196
|
+
console.warn(`[unpack-store] 缓存 prefix 与预期不符(fp=${fp} rel=${bad.rel}):`
|
|
197
|
+
+ `缓存=${bad.cached} 预期=${bad.expect}`
|
|
198
|
+
+ ' —— 插件可能改了 prefix 算法而指纹没覆盖到;本次仍按缓存返回,'
|
|
199
|
+
+ '但客户端按新形态拼的地址会 miss 并回落网络');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 把"缓存的实测值"与"现场问到的声明值"合成一份准入用的内存声明。
|
|
204
|
+
*
|
|
205
|
+
* 合并方向:cached 在前、est 在后 —— 解压量以本次探测为准,
|
|
206
|
+
* 而 cached 里的 measuredPeakBytes / samples 等字段必须保留(它们是"实测回填"的累积状态)。
|
|
207
|
+
* 问不到声明(est 为空)⇒ **原样返回 cached**,让准入走 legacy 的源包大小粗估。
|
|
208
|
+
*
|
|
209
|
+
* @param {object|null} cached readProbe 的结果
|
|
210
|
+
* @param {object|null} est askDeclaredSafely 的结果
|
|
211
|
+
* @returns {object|null}
|
|
212
|
+
*/
|
|
213
|
+
export function mergeDeclared(cached, est) {
|
|
214
|
+
if (!est) return cached;
|
|
215
|
+
return { ...(cached || {}), ...est, probeAt: Date.now() };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function createUnpackStore(opts) {
|
|
219
|
+
// 配置错必须在入口崩:否则 undefined 会一路变成下游的 "path argument must be a string",
|
|
220
|
+
// 堆栈指不到真正的错处(本项目已多次踩过"错在 A、报在 Z")。
|
|
221
|
+
if (typeof (opts || {}).rootDir !== 'string' || !opts.rootDir) {
|
|
222
|
+
throw new Error('createUnpackStore: rootDir 必填(解包缓存根目录,如 <dataRoot>/unpacked)');
|
|
223
|
+
}
|
|
224
|
+
// pluginDir 只影响指纹里的 pluginVersion;缺省会让"插件升级后缓存不失效"成为静默错误(比崩更糟)
|
|
225
|
+
if (typeof (opts || {}).pluginDir !== 'string' || !opts.pluginDir) {
|
|
226
|
+
throw new Error('createUnpackStore: pluginDir 必填(指纹要含 pluginVersion,否则插件升级后缓存不会失效)');
|
|
227
|
+
}
|
|
228
|
+
const rootDir = opts.rootDir;
|
|
229
|
+
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
|
|
230
|
+
const version = pluginVersion(opts.pluginDir);
|
|
231
|
+
const counters = {
|
|
232
|
+
unpackCount: 0, hits: 0, misses: 0, failures: 0, lastUnpackMs: 0, peakRss: 0,
|
|
233
|
+
// miss 的具体原因(验收"改 mtime → 重新解包"时要能看出是 source-changed 而不是别的原因)
|
|
234
|
+
lastMissReason: null,
|
|
235
|
+
peekHits: 0, // 前台只查缓存命中的次数(形态 C 的主指标)
|
|
236
|
+
lastFailReason: null, // 最近一次拒绝/失败的原因(insufficient-memory 等)
|
|
237
|
+
};
|
|
238
|
+
/** fp → Promise(同一包的并发只解一次) */
|
|
239
|
+
const inflight = new Map();
|
|
240
|
+
/** fp → Set<订阅者>:同一 fp 的后到请求挂这里,事件广播给所有等待者(一个包仍只解一次) */
|
|
241
|
+
const subscribers = new Map();
|
|
242
|
+
/** 串行队列:解包是单核密集操作,串行避免抢渲染 */
|
|
243
|
+
let chain = Promise.resolve();
|
|
244
|
+
const enqueue = opts.enqueue || ((fn) => {
|
|
245
|
+
const next = chain.then(fn, fn);
|
|
246
|
+
chain = next.then(() => undefined, () => undefined);
|
|
247
|
+
return next;
|
|
248
|
+
});
|
|
249
|
+
let lastCleanupAt = 0;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 读当前 RSS 与**内核记录的峰值**(/proc/self/status 的 VmHWM)。
|
|
253
|
+
*
|
|
254
|
+
* 为什么用 VmHWM:解包的峰值是**瞬时**的(插件 resolve 里一次性产出整包文件),
|
|
255
|
+
* 300ms 轮询采样会系统性漏掉它——实测采样说 171MB,而内核 VmHWM 是 491MB(差 2.9 倍)。
|
|
256
|
+
* 验收内存必须用内核峰值,不能用抽样最小值。
|
|
257
|
+
* @returns {{rss: number, peak: number, source: 'vmhwm'|'sample'}}
|
|
258
|
+
*/
|
|
259
|
+
// rssNow 由 mem-probe.mjs 提供(worker 与主线程同一实现);这里只保留采样兜底:
|
|
260
|
+
// 非 Linux 上 VmHWM 读不到时,用 counters.peakRss 作为峰值基线,所以调用点要把它传进去。
|
|
261
|
+
|
|
262
|
+
/** 采样 RSS 峰值(VMHWM 不可用时的兜底;同时保留抽样序列的最大值) */
|
|
263
|
+
function sampleRss() {
|
|
264
|
+
const rss = process.memoryUsage().rss;
|
|
265
|
+
if (rss > counters.peakRss) counters.peakRss = rss;
|
|
266
|
+
return rss;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 指纹:源文件 stat + 插件身份(详见文件头第 1 条)。
|
|
271
|
+
*
|
|
272
|
+
* 插件身份取 providerFingerprint()(所有可解包插件的集合)而不是写死某个插件名:
|
|
273
|
+
* 单插件时它返回的就是那个插件 id,与历史硬编码值**逐字相同**,所以现有缓存不会因这次通用化失效。
|
|
274
|
+
*/
|
|
275
|
+
function fingerprintOf({ rootId, rel, stat }) {
|
|
276
|
+
const raw = [rootId, rel, stat.size, Math.round(stat.mtimeMs), providerFingerprint(), version].join('\0');
|
|
277
|
+
return crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const fpDir = (fp) => path.join(rootDir, fp);
|
|
281
|
+
|
|
282
|
+
// probe.json 的读**与写**都由 probe-store.mjs 提供 —— worker 里解完要写同一份 probe.json
|
|
283
|
+
// (recordMeasuredPeak 与 writeResult 的"取走旧 probe → rename → 写回"强耦合),
|
|
284
|
+
// 主线程的准入路径也要读它。两边共用一个实现,避免两份逻辑各自漂移。
|
|
285
|
+
const probeStore = createProbeStore({ rootDir });
|
|
286
|
+
const readProbe = (fp) => probeStore.read(fp);
|
|
287
|
+
|
|
288
|
+
// 解包 worker 客户端:**常驻 1 个**(与既有串行队列一致,不并发解包)。
|
|
289
|
+
// 硬超时 120s 只用于"防死循环把队列永久堵住",不是"约束合法解包" ——
|
|
290
|
+
// 锚点包 azurlane_bundle/live2d/z46_2(解压 33.4MB 的 LZMA)实测墙钟 17.83s、unpackMs 9416ms,
|
|
291
|
+
// 120s 有 6.7 倍余量,正常解包一辈子不该触发它。
|
|
292
|
+
// ⚠️ 别把这个 120s 与"客户端不设总时长上限"当成矛盾,两者防的不是一回事:
|
|
293
|
+
// 这里防的是「worker 死循环把串行队列永久堵死」(服务端自保,与有没有人看无关);
|
|
294
|
+
// 客户端防的是「连续 15s 没有任何进展」(停摆判据,见契约 §四),慢但仍在推事件不算失败。
|
|
295
|
+
const workerClient = createUnpackWorkerClient({
|
|
296
|
+
rootDir,
|
|
297
|
+
timeoutMs: 120000,
|
|
298
|
+
log: (level, message, extra) => {
|
|
299
|
+
// 服务端日志(stdout 会进 runtime/model-stage-web.log);超时/崩溃这类事件必须留痕,
|
|
300
|
+
// 否则真触发时没人知道发生过。
|
|
301
|
+
const fn = level === 'error' ? console.error : console.warn;
|
|
302
|
+
fn(`[unpack-worker] ${message}`, extra ? JSON.stringify(extra) : '');
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 本次准入要用的内存声明:缓存优先,其次现场问插件(轻量,不解压),都没有返回 null。
|
|
308
|
+
* @param {string} fp 指纹
|
|
309
|
+
* @param {{rootId:string, rel:string, absPath:string}} req
|
|
310
|
+
*/
|
|
311
|
+
function memoryDeclaration(fp, req) {
|
|
312
|
+
const cached = readProbe(fp);
|
|
313
|
+
// 缓存里只有解压量、实测峰值、采样次数,**不含插件声明的 peakBytes**(字段见 probe-store.mjs)。
|
|
314
|
+
// 所以缓存命中时不能直接返回:那样准入拿到的"声明值"会退化成"源包大小粗估"
|
|
315
|
+
// (实测:21MB 的 LZMA 包只估到约 200MB,而插件表给 732MB),
|
|
316
|
+
// 再叠加 measured 会被进程状态压低(同一进程连解两个 21MB 包分别报 225MB / 1195MB),
|
|
317
|
+
// 两个低估叠加 ⇒ 准入形同不存在。这里补问一次声明(只读块表,P90 < 1ms)。
|
|
318
|
+
const hasDecl = cached && cached.peakBytes > 0;
|
|
319
|
+
if (hasDecl) return cached;
|
|
320
|
+
return mergeDeclared(cached, askDeclaredSafely(req));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 解包完成后回填**实测峰值**(契约 §2.2 第①段的数据来源)。
|
|
325
|
+
*
|
|
326
|
+
* 计算式:`峰值 = before.rss + max(0, after.peak - before.peak)`(rss=VmRSS,peak=VmHWM 内核峰值)。
|
|
327
|
+
* 本次创了新高 = "起始占用 + 本次顶高的量";没创新高说明历史上解过更大的包,
|
|
328
|
+
* 此时取 max(历史值, 本次) —— 上包络语义,宁高不低。
|
|
329
|
+
*
|
|
330
|
+
* ⚠️ 这个数**不是这一包自身的成本**,别当包级峰值用:
|
|
331
|
+
* VmHWM **只增不减**,所以它实际是「本次把峰值顶高了多少 **+ 当时进程已占多少**」。
|
|
332
|
+
* 同进程连续解包时,后续包的 `after.peak - before.peak` 会退化为 0 ⇒ measured 约等于 `before.rss`
|
|
333
|
+
* (而那时 RSS 已被上一个包撑起来)。
|
|
334
|
+
* 实测:同一进程先后解两个 21MB 包,measured 分别 **225MB** 与 **1195MB**;
|
|
335
|
+
* 干净进程测同类包是 **654MB**。
|
|
336
|
+
*
|
|
337
|
+
* ⇒ 它只能当**下限兜底**:高于插件声明说明这包真更贵(信它);低于声明只是进程状态,不能拿它压声明。
|
|
338
|
+
* 取舍实现在 capacity.mjs 的 estimatePeakWithBasis。
|
|
339
|
+
* ⇒ 想要真正的包级峰值得换口径(子进程解包 / 解包前重置峰值基线),那会动解包主链路,
|
|
340
|
+
* 代价与收益不匹配,故保持现状。
|
|
341
|
+
* @param {string} fp
|
|
342
|
+
* @param {{rss:number, peak:number}} before
|
|
343
|
+
* @param {{rss:number, peak:number}} after
|
|
344
|
+
*/
|
|
345
|
+
// recordMeasuredPeak 已随解包一起搬进 worker(unpack-worker.mjs 调 probe-store 的同一实现)。
|
|
346
|
+
// 留在主线程没有调用点;两份实现会在下次只改一处时静默失效,所以这里不保留委托版。
|
|
347
|
+
const indexPath = (fp) => path.join(fpDir(fp), 'index.json');
|
|
348
|
+
|
|
349
|
+
/** 读索引(不存在/损坏 → null) */
|
|
350
|
+
function readIndex(fp) {
|
|
351
|
+
try {
|
|
352
|
+
const j = JSON.parse(fs.readFileSync(indexPath(fp), 'utf8'));
|
|
353
|
+
return j && j.v === 1 && Array.isArray(j.files) ? j : null;
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 命中判定:索引在 + 源 stat 未变 + 清单里每个文件都真的在盘上。
|
|
361
|
+
* @returns {{hit: boolean, reason?: 'no-index'|'source-changed'|'empty-list'|'file-missing'}}
|
|
362
|
+
*/
|
|
363
|
+
function hitCheck(index, stat) {
|
|
364
|
+
if (!index || !index.source) return { hit: false, reason: 'no-index' };
|
|
365
|
+
if (index.source.size !== stat.size || Math.round(index.source.mtimeMs) !== Math.round(stat.mtimeMs)) {
|
|
366
|
+
return { hit: false, reason: 'source-changed' };
|
|
367
|
+
}
|
|
368
|
+
if (!index.files.length) return { hit: false, reason: 'empty-list' };
|
|
369
|
+
for (const f of index.files) {
|
|
370
|
+
try {
|
|
371
|
+
if (!fs.statSync(path.join(fpDir(index.fingerprint), 'files', f.path)).isFile()) return { hit: false, reason: 'file-missing' };
|
|
372
|
+
} catch {
|
|
373
|
+
return { hit: false, reason: 'file-missing' };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { hit: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 命中时更新 lastUsedAt(LRU 依据),失败不影响命中 */
|
|
380
|
+
/**
|
|
381
|
+
* 命中时更新 lastUsedAt(LRU 依据),失败不影响命中。
|
|
382
|
+
*
|
|
383
|
+
* ⚠️ 这是 index.json 的**第二个写者**(第一个是 worker 里的 writeResult,它把 index.json
|
|
384
|
+
* 连同 files/ 一起 rename 成 <fp>/ —— rename 必须带上 index.json 才能保证"产物与索引同时就位",
|
|
385
|
+
* 所以那份留在 worker,不搬回主线程)。
|
|
386
|
+
* **两者不会并发**:解包进行中该 fp 一定在 `inflight` Map 里(见 resolve 的 `inflight.set`),
|
|
387
|
+
* 此时任何对同一 fp 的请求都由 `inflight.has(fp)` 直接复用同一个 promise,
|
|
388
|
+
* 不会走到 hit/touch 这条路径;而 worker 的 rename 发生在那个 promise resolve 之前。
|
|
389
|
+
* ⇒ touch 只可能在"该 fp 没有解包在跑"时执行。改这段前请重新确认这条互斥仍然成立。
|
|
390
|
+
*/
|
|
391
|
+
function touch(fp, index) {
|
|
392
|
+
try {
|
|
393
|
+
fs.writeFileSync(indexPath(fp), JSON.stringify({ ...index, lastUsedAt: new Date().toISOString() }, null, 2), 'utf8');
|
|
394
|
+
} catch { /* 只影响 LRU 精度 */ }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 原子落盘(writeResult)整段在 write-result.mjs 里,由 worker 调用 —— 主线程**不再有任何
|
|
398
|
+
// "写产物"的路径**。两份原子写 = 下次只改一处、另一处静默失效,本项目栽过,所以这里不留备用实现。
|
|
399
|
+
|
|
400
|
+
/** LRU 清理:超 maxDirs / maxBytes 时删最久未用的(直接删,理由见文件头第 4 条) */
|
|
401
|
+
function cleanup() {
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) return { skipped: true };
|
|
404
|
+
lastCleanupAt = now;
|
|
405
|
+
let deleted = 0;
|
|
406
|
+
try {
|
|
407
|
+
const entries = fs.readdirSync(rootDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
408
|
+
const items = [];
|
|
409
|
+
for (const e of entries) {
|
|
410
|
+
const full = path.join(rootDir, e.name);
|
|
411
|
+
if (e.name.startsWith('.tmp-')) { // 半成品残留(上次崩在中途)
|
|
412
|
+
try { fs.rmSync(full, { recursive: true, force: true }); deleted += 1; } catch { /* 忽略 */ }
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const idx = readIndex(e.name);
|
|
416
|
+
items.push({ fp: e.name, full, at: Date.parse((idx && idx.lastUsedAt) || 0) || 0, bytes: dirBytes(full) });
|
|
417
|
+
}
|
|
418
|
+
items.sort((a, b) => a.at - b.at); // 最久未用在前
|
|
419
|
+
let total = items.reduce((s, i) => s + i.bytes, 0);
|
|
420
|
+
while (items.length && (items.length > limits.maxDirs || total > limits.maxBytes)) {
|
|
421
|
+
const victim = items.shift();
|
|
422
|
+
try {
|
|
423
|
+
fs.rmSync(victim.full, { recursive: true, force: true });
|
|
424
|
+
total -= victim.bytes;
|
|
425
|
+
deleted += 1;
|
|
426
|
+
} catch { /* 并发删除中,忽略 */ }
|
|
427
|
+
}
|
|
428
|
+
} catch { /* 目录刚建/刚删,忽略 */ }
|
|
429
|
+
return { deleted };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 未命中原因推断(跨目录比对):指纹里含 mtime/size/pluginVersion,
|
|
434
|
+
* 这三者任一变化都会**指向一个全新的目录**,所以单看本 fp 永远只能报 no-index。
|
|
435
|
+
* 这里扫一遍已有目录里"同一个源文件"的旧记录,把原因说清楚:
|
|
436
|
+
* · 同源 stat、版本不同 → plugin-version-changed(旧 1 → 新 2)
|
|
437
|
+
* · 同源 rel、stat 不同 → source-changed(mtime 或 size 变了)
|
|
438
|
+
* @param {{rootId: string, rel: string, stat: fs.Stats}} req
|
|
439
|
+
* @returns {string|null}
|
|
440
|
+
*/
|
|
441
|
+
function detectMissCause({ rootId, rel, stat }) {
|
|
442
|
+
try {
|
|
443
|
+
for (const e of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
444
|
+
if (!e.isDirectory() || e.name.startsWith('.tmp-')) continue;
|
|
445
|
+
const idx = readIndex(e.name);
|
|
446
|
+
const src = idx && idx.source;
|
|
447
|
+
if (!src || src.rootId !== rootId || src.rel !== rel) continue;
|
|
448
|
+
const sameStat = src.size === stat.size && Math.round(src.mtimeMs) === Math.round(stat.mtimeMs);
|
|
449
|
+
const sameVersion = String(idx.pluginVersion) === String(version);
|
|
450
|
+
if (sameStat && !sameVersion) return `plugin-version-changed(${idx.pluginVersion} → ${version})`;
|
|
451
|
+
if (!sameStat) return `source-changed(size ${src.size}→${stat.size},mtime ${Math.round(src.mtimeMs)}→${Math.round(stat.mtimeMs)})`;
|
|
452
|
+
}
|
|
453
|
+
} catch { /* 目录未建 */ }
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 只查缓存、**绝不触发解包**(形态 C 的前台路径用它:miss 就让前端走本地,绝不在这里抢 CPU)。
|
|
459
|
+
* @param {{rootId: string, rel: string, absPath: string}} req
|
|
460
|
+
*/
|
|
461
|
+
function peek(req) {
|
|
462
|
+
let stat;
|
|
463
|
+
try {
|
|
464
|
+
stat = fs.statSync(req.absPath);
|
|
465
|
+
} catch {
|
|
466
|
+
return { ok: false, hit: false, reason: 'source-missing' };
|
|
467
|
+
}
|
|
468
|
+
const fp = fingerprintOf({ ...req, stat });
|
|
469
|
+
const idx = readIndex(fp);
|
|
470
|
+
const verdict = hitCheck(idx, stat);
|
|
471
|
+
if (!verdict.hit) return { ok: true, hit: false, fingerprint: fp, reason: verdict.reason || 'no-index' };
|
|
472
|
+
counters.peekHits += 1;
|
|
473
|
+
touch(fp, idx);
|
|
474
|
+
warnIfPrefixStale(fp, idx);
|
|
475
|
+
return { ok: true, hit: true, fingerprint: fp, prefix: idx.prefix, entry: idx.entry, files: idx.files, unpackMs: 0 };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 解析一个资源包:命中直接返回清单;未命中则解包落盘(同 fp 并发共用一次解包)。
|
|
480
|
+
* 事件流(契约 `unpack/events/v1`):可选的 `opts.onEvent` 收进度事件。
|
|
481
|
+
* **不传 ⇒ 一个事件都不发、代码路径与从前完全一致**("不带 stream 逐字同现状"的落点)。
|
|
482
|
+
* 事件消费方(写 SSE 帧)抛错一律吞掉:写已断开的连接是正常现象,不能带崩解包。
|
|
483
|
+
*
|
|
484
|
+
* @param {{rootId: string, rel: string, absPath: string}} req
|
|
485
|
+
* @param {{onEvent?: (evt: {type: string, data: object}) => void}} [opts]
|
|
486
|
+
*/
|
|
487
|
+
async function resolve(req, opts = {}) {
|
|
488
|
+
const emit = typeof opts.onEvent === 'function' ? opts.onEvent : null;
|
|
489
|
+
const stat = fs.statSync(req.absPath);
|
|
490
|
+
const fp = fingerprintOf({ ...req, stat });
|
|
491
|
+
/**
|
|
492
|
+
* 发一条事件:广播给「本请求的订阅者」+「同一 fp 的后到等待者」。
|
|
493
|
+
* 两侧抛错都静默吞 —— 写已断开的连接是正常现象(契约 §五),不能让事件消费方带崩解包。
|
|
494
|
+
*/
|
|
495
|
+
const fire = (type, data) => {
|
|
496
|
+
const evt = { type, data };
|
|
497
|
+
if (emit) { try { emit(evt); } catch { /* 静默吞 */ } }
|
|
498
|
+
const set = subscribers.get(fp);
|
|
499
|
+
if (set) for (const fn of set) { try { fn(evt); } catch { /* 静默吞 */ } }
|
|
500
|
+
};
|
|
501
|
+
const cachedIdx = readIndex(fp);
|
|
502
|
+
const verdict = hitCheck(cachedIdx, stat);
|
|
503
|
+
if (verdict.hit) {
|
|
504
|
+
counters.hits += 1;
|
|
505
|
+
touch(fp, cachedIdx);
|
|
506
|
+
warnIfPrefixStale(fp, cachedIdx);
|
|
507
|
+
cleanup();
|
|
508
|
+
// 命中也要发一帧(契约 §2.1):不发的话 stream 请求一帧都收不到,客户端只能干等 15s 停摆。
|
|
509
|
+
// 命中路径只发 done,不发 queued/start/phase(没有解包过程可报)。
|
|
510
|
+
fire('done', { fp, hit: true, prefix: cachedIdx.prefix, entry: cachedIdx.entry, files: cachedIdx.files, unpackMs: 0 });
|
|
511
|
+
return { ok: true, hit: true, fingerprint: fp, prefix: cachedIdx.prefix, entry: cachedIdx.entry, files: cachedIdx.files, unpackMs: 0,
|
|
512
|
+
// 命中缓存也要报出"当年是哪个插件解的":契约 §二 的 loadResult.provider 决定展示归属,
|
|
513
|
+
// index.json 里记录的 pluginId 是唯一可信来源(不能按当前注册表重新猜)。
|
|
514
|
+
provider: cachedIdx.pluginId || null };
|
|
515
|
+
}
|
|
516
|
+
if (inflight.has(fp)) {
|
|
517
|
+
// 同一 fp 已在解:**不新建任务**,只挂一个订阅者并补一条 queued{waiting}
|
|
518
|
+
// waiting 取 inflight.size(粗粒度、含自己这个 fp);不发"瞬时精确 0"(契约 §2.1)。
|
|
519
|
+
if (emit) {
|
|
520
|
+
let set = subscribers.get(fp);
|
|
521
|
+
if (!set) { set = new Set(); subscribers.set(fp, set); }
|
|
522
|
+
set.add(emit);
|
|
523
|
+
// 只发给自己这一条,**不能用 fire()**:fire 会广播,而自己此刻已在 subscribers 里
|
|
524
|
+
// ⇒ 同一个 queued 会收到两遍(实测踩到过:后到客户端收到 2 条 queued)。
|
|
525
|
+
try { emit({ type: 'queued', data: { waiting: inflight.size } }); } catch { /* 静默吞 */ }
|
|
526
|
+
}
|
|
527
|
+
return inflight.get(fp); // 并发去重:同一 fp 只解一次
|
|
528
|
+
}
|
|
529
|
+
counters.misses += 1;
|
|
530
|
+
// 未命中原因:优先报"插件版本变了"(跨目录比对),否则报 hitCheck 给的具体原因
|
|
531
|
+
counters.lastMissReason = detectMissCause({ ...req, stat }) || verdict.reason || 'no-index';
|
|
532
|
+
const task = enqueue(async () => {
|
|
533
|
+
const t0 = Date.now();
|
|
534
|
+
// 解包前按**当前可用内存**现判(不缓存、不看包大小阈值):
|
|
535
|
+
// 预估峰值 = 源大小 × 分段倍数(实测上包络),超过"可用内存的一半"就拒绝,
|
|
536
|
+
// 否则一次解包可能把机器拖到换页(42MB 包实测峰值 479MB)。
|
|
537
|
+
// 内存保护开关:默认(不带 allowOverLimit)仍是"超预算就拒",与既有行为一致;
|
|
538
|
+
// 面板关掉开关后前端带 allowOverLimit ⇒ 放行但保留真实数字(overLimit 标记供上层提示)。
|
|
539
|
+
// 内存声明:缓存优先 → 现场问插件(只读块表,P90 < 1ms)→ 都没有则回落"源包大小分段"
|
|
540
|
+
const decl = memoryDeclaration(fp, req);
|
|
541
|
+
const mem = checkUnpackMemory(stat.size, { allowOverLimit: req.allowOverLimit === true, memory: decl || undefined });
|
|
542
|
+
if (!mem.ok) {
|
|
543
|
+
counters.failures += 1;
|
|
544
|
+
counters.lastFailReason = mem.reason;
|
|
545
|
+
fire('error', { reason: mem.reason, message: mem.message });
|
|
546
|
+
return { ok: false, fingerprint: fp, reason: mem.reason, message: mem.message, budgetMb: mem.budgetMb, peakEstMb: mem.peakEstMb, availMb: mem.availMb };
|
|
547
|
+
}
|
|
548
|
+
sampleRss();
|
|
549
|
+
const memBefore = rssNow(counters.peakRss);
|
|
550
|
+
// 分段打点(先量再改):解压 / 落盘 / 索引探针 三段分别计时。
|
|
551
|
+
// 为什么要量:服务端解包 10~11s,而前端本地解同一包只要 4s ——
|
|
552
|
+
// 慢的到底是解压还是写盘,不量就只能猜(写盘目标是 SMB 挂载 + 23~39 个小文件,最可疑)。
|
|
553
|
+
// 解包 + 落盘 + 写 probe **整段在 worker 里**(主线程不再碰任何产物文件):
|
|
554
|
+
// 这是搬 worker 的唯一目的 —— 让这几秒(锚点包实测墙钟 17.8s)不占住事件循环。
|
|
555
|
+
// start:worker 真正要开工了(此前是排队与内存准入)。三个字段对齐契约 §2.1。
|
|
556
|
+
fire('start', { fp, rootId: req.rootId, rel: req.rel });
|
|
557
|
+
const tUnpack0 = Date.now();
|
|
558
|
+
// 进度**实时**转发(只有 exec 插件会上报;node 插件进程内同步执行,没有中间边界)。
|
|
559
|
+
// 为什么记 lastMs:收尾要判断"某档到底发过没有" —— 发过的不能再补一条,
|
|
560
|
+
// 否则前端/SSE 订阅方会看到同一档两条(ms 语义还不同:实时那条是"相对请求起点",
|
|
561
|
+
// 收尾补的那条是"该段耗时")。
|
|
562
|
+
const livePhases = new Map();
|
|
563
|
+
const reply = await workerClient.run({
|
|
564
|
+
rootId: req.rootId,
|
|
565
|
+
rel: req.rel,
|
|
566
|
+
absPath: req.absPath,
|
|
567
|
+
fp,
|
|
568
|
+
meta: {
|
|
569
|
+
...req,
|
|
570
|
+
size: stat.size,
|
|
571
|
+
mtimeMs: stat.mtimeMs,
|
|
572
|
+
pluginId: runnerInfo().pluginId,
|
|
573
|
+
pluginVersion: version,
|
|
574
|
+
},
|
|
575
|
+
decl,
|
|
576
|
+
}, {
|
|
577
|
+
onProgress: ({ phase, ms }) => {
|
|
578
|
+
// 契约 §四·⑨:同一档可以多次上报,**以最后一次为准** ⇒ 每次都照发(后到的覆盖前一条)
|
|
579
|
+
if (!phase) return;
|
|
580
|
+
livePhases.set(phase, ms);
|
|
581
|
+
fire('phase', { name: phase, ms });
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
const unpackWallMs = Date.now() - tUnpack0;
|
|
585
|
+
if (!reply.ok) {
|
|
586
|
+
counters.failures += 1;
|
|
587
|
+
fire('error', { reason: reply.reason, message: reply.message });
|
|
588
|
+
return { ok: false, fingerprint: fp, reason: reply.reason, message: reply.message, provider: null };
|
|
589
|
+
}
|
|
590
|
+
const index = reply.index;
|
|
591
|
+
const writeMs = reply.writeMs || 0;
|
|
592
|
+
const probeMs = reply.probeMs || 0;
|
|
593
|
+
counters.unpackCount += 1;
|
|
594
|
+
counters.lastUnpackMs = Date.now() - t0;
|
|
595
|
+
sampleRss();
|
|
596
|
+
// phases 随响应回给调用方(排查与验收要能看到"时间花在哪一段")
|
|
597
|
+
// 解压段再细分:readMs = 从盘读整包(19MB 走 SMB 可能就要几秒)、pluginResolveMs = 插件真正解压
|
|
598
|
+
const phases = {
|
|
599
|
+
readMs: reply.readMs,
|
|
600
|
+
pluginResolveMs: reply.unpackMs,
|
|
601
|
+
unpackWallMs,
|
|
602
|
+
writeMs,
|
|
603
|
+
probeMs,
|
|
604
|
+
totalMs: Date.now() - t0,
|
|
605
|
+
files: index.files.length,
|
|
606
|
+
};
|
|
607
|
+
// phase 三段:read / resolve / write(名字对齐 phases 键,契约 §2.2)。
|
|
608
|
+
// exec 插件已在上面**实时**发过(livePhases 有记录的那几档),这里只补发**没发过的** ——
|
|
609
|
+
// 否则同一档会出现两条,而且两条的 ms 语义不同(实时=相对请求起点,补发=该段耗时)。
|
|
610
|
+
// node 插件不发实时帧 ⇒ 每档都落到这里补发,行为与改动前**逐字一致**。
|
|
611
|
+
// 档名与 phases 字段名都来自 PROGRESS_PHASES(经 SSE_PHASE_FIELDS 派生)—— 这里不写死档名,
|
|
612
|
+
// 改档名/加档位只改 exec.mjs 那张表,SSE 与 exec 白名单自动保持一致。
|
|
613
|
+
for (const { name, msField } of SSE_PHASE_FIELDS) {
|
|
614
|
+
if (livePhases.has(name)) continue;
|
|
615
|
+
// 防御:msField 只保证"这一档有字段名",不保证 phases 里真有那个键 ——
|
|
616
|
+
// phases 的键仍是本文件手写的(readMs/pluginResolveMs/writeMs)。
|
|
617
|
+
// 若有人改了 phases 的键却忘了改表里的 msField,这里会拿到 undefined,
|
|
618
|
+
// 前端画进度时表现为"有这一档但没有数值"。宁可记一条 warn 并跳过,
|
|
619
|
+
// 也不要把 undefined 当毫秒数发出去(那会让前端把 NaN 画进进度条)。
|
|
620
|
+
const ms = phases[msField];
|
|
621
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
622
|
+
// 该文件没有统一的 log 注入点(只有零散的 console.warn),这里沿用同一风格
|
|
623
|
+
console.warn(`[unpack-store] phase 补发缺少数值,已跳过:phase=${name} msField=${msField} keys=${Object.keys(phases).join(',')}`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
fire('phase', { name, ms });
|
|
627
|
+
}
|
|
628
|
+
fire('done', { fp, hit: false, files: index.files, unpackMs: index.unpackMs, phases });
|
|
629
|
+
return { ok: true, hit: false, fingerprint: fp, prefix: index.prefix, entry: index.entry, files: index.files, unpackMs: index.unpackMs, phases, memBasis: mem.basis, peakEstMb: mem.peakEstMb,
|
|
630
|
+
// 实际解出这个包的插件 id(写 entry-states 的 loadResult.provider 用它 ⇒ 展示归属与颜色)
|
|
631
|
+
provider: index.pluginId || runnerInfo().pluginId || null };
|
|
632
|
+
}).finally(() => {
|
|
633
|
+
inflight.delete(fp);
|
|
634
|
+
subscribers.delete(fp); // 任务结束清订阅者表,避免 fp 复用时把上一轮的监听者带进来
|
|
635
|
+
});
|
|
636
|
+
inflight.set(fp, task);
|
|
637
|
+
// queued:入队后立即发一条(waiting 取 inflight.size,粗粒度、含自己这个 fp;契约 §2.1)
|
|
638
|
+
fire('queued', { waiting: inflight.size });
|
|
639
|
+
const out = await task;
|
|
640
|
+
cleanup();
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** 单个产物文件的绝对路径(路由用;调用方必须先校验 path 在 index.files 里) */
|
|
645
|
+
function filePathOf(fp, relPath) {
|
|
646
|
+
return path.join(fpDir(fp), 'files', relPath);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 对外暴露"这个包的内存声明"(/api/unpack/capacity 用)。
|
|
651
|
+
* 与准入判断走**同一个来源**:缓存命中 → 插件 estimateMemory(含 peakBytes)→ 都没有返回 null。
|
|
652
|
+
* @param {{rootId:string, rel:string, absPath:string}} req
|
|
653
|
+
*/
|
|
654
|
+
function memoryOf(req) {
|
|
655
|
+
try {
|
|
656
|
+
const stat = fs.statSync(req.absPath);
|
|
657
|
+
const fp = fingerprintOf({ ...req, stat });
|
|
658
|
+
return memoryDeclaration(fp, req);
|
|
659
|
+
} catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 状态(/api/unpack/stats 用;peakRss 给验收与回归看) */
|
|
665
|
+
function stats() {
|
|
666
|
+
const mem = rssNow();
|
|
667
|
+
let dirs = 0;
|
|
668
|
+
let bytes = 0;
|
|
669
|
+
try {
|
|
670
|
+
for (const e of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
671
|
+
if (!e.isDirectory() || e.name.startsWith('.tmp-')) continue;
|
|
672
|
+
dirs += 1;
|
|
673
|
+
if (dirs <= 50) bytes += dirBytes(path.join(rootDir, e.name));
|
|
674
|
+
}
|
|
675
|
+
} catch { /* 目录未建 */ }
|
|
676
|
+
return {
|
|
677
|
+
...counters,
|
|
678
|
+
// peakRssMb 用内核 VmHWM(真峰值);sampledPeakRssMb 保留旧抽样值,便于看出"抽样会漏多少"
|
|
679
|
+
peakRssMb: Math.round(mem.peak / BYTES_PER_MB),
|
|
680
|
+
peakSource: mem.source,
|
|
681
|
+
sampledPeakRssMb: Math.round((counters.peakRss || 0) / BYTES_PER_MB),
|
|
682
|
+
rssMb: Math.round(mem.rss / BYTES_PER_MB),
|
|
683
|
+
dirs,
|
|
684
|
+
bytesMb: Math.round(bytes / BYTES_PER_MB),
|
|
685
|
+
inflight: inflight.size,
|
|
686
|
+
limits,
|
|
687
|
+
runner: runnerInfo(),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 把**前端解出来的产物**落盘到同一个缓存键(新增的第二个写入者)。
|
|
693
|
+
*
|
|
694
|
+
* 为什么要有第二个写入者:服务端解包要 11.4s(慢在 vm 沙箱里跑插件,实测解压占 93.6%),
|
|
695
|
+
* 而前端本地解同一个包只要 4.3s。与其把服务端变快(那要动隔离模型),
|
|
696
|
+
* 不如让前台走快的(前端解),解完把产物交给服务端存起来 —— 这样:
|
|
697
|
+
* 首次 4.3s(原来等 11.4s)、第二次 137ms(命中),服务端那条慢路只留给预热/直调。
|
|
698
|
+
*
|
|
699
|
+
* 硬约束:
|
|
700
|
+
* · **指纹由服务端自己算**(它拿得到源包 absPath + stat)。前端只报 rootId/rel 与产物,
|
|
701
|
+
* 不重复实现 hash —— 否则键一旦不一致,就会出现"存进去了但查不到"。
|
|
702
|
+
* · 已存在同一指纹的缓存 ⇒ 直接返回 hit(幂等,不重复写盘)。
|
|
703
|
+
* · 落盘失败**不影响本次加载**:数据在前端内存里已经可用,这里失败只让下次再解一遍。
|
|
704
|
+
*
|
|
705
|
+
* @param {{rootId:string, rel:string, absPath:string, prefix:string, entry:string,
|
|
706
|
+
* files: Array<{name:string, bytes:Buffer}>, pluginId?:string, pluginVersion?:string}} req
|
|
707
|
+
* @returns {Promise<{ok:boolean, hit?:boolean, fingerprint?:string, files?:number, ms?:number, reason?:string, message?:string}>}
|
|
708
|
+
*/
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* 只读:**已解包过的包**分别被哪个插件解出来的(key = `${rootId}/${rel}` → pluginId)。
|
|
713
|
+
*
|
|
714
|
+
* 用途只有一个:列表徽标的展示归属。资源包在**解包前**谁也判不出该归哪个插件
|
|
715
|
+
* (两个插件都声明 fileKinds:["bundle"]),所以"没解包过 ⇒ 没有归属 ⇒ 中性色",
|
|
716
|
+
* 解包过就以 index.json 里真实记录的 pluginId 为准 —— 那是唯一可信的来源。
|
|
717
|
+
* 实现:扫一遍缓存目录读 index.json(每次 catalog 只调一次,不按条目查)。
|
|
718
|
+
* **绝不触发重建**,也不改任何缓存状态。
|
|
719
|
+
* @returns {Map<string, string>}
|
|
720
|
+
*/
|
|
721
|
+
function cachedProviders() {
|
|
722
|
+
const out = new Map();
|
|
723
|
+
try {
|
|
724
|
+
for (const e of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
725
|
+
if (!e.isDirectory() || e.name.startsWith('.tmp-')) continue;
|
|
726
|
+
const idx = readIndex(e.name);
|
|
727
|
+
const src = idx && idx.source;
|
|
728
|
+
if (!src || !src.rootId || !src.rel || !idx.pluginId) continue;
|
|
729
|
+
out.set(`${src.rootId}/${src.rel}`, String(idx.pluginId));
|
|
730
|
+
}
|
|
731
|
+
} catch { /* 目录未建:没有任何已解包的包,返回空表 */ }
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return { resolve, memoryOf, peek, readIndex, filePathOf, stats, cleanup, fingerprintOf, cachedProviders };
|
|
736
|
+
}
|