@modusensus/dsh-mneme 0.8.13 → 0.8.15

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.
Files changed (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,348 +1,348 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { createServer } from "node:http";
4
- import { createHash } from "node:crypto";
5
- import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join } from "node:path";
8
- import { createResilientFetch, installResilientFetch } from "../src/runtime/model-fetch.js";
9
-
10
- // 模型文件取件层(issue #194)。transformers.js 下载几百 MB~1GB 的模型文件时,
11
- // undici socket 中途被重置(TypeError: terminated)会让整份下载作废重来;报告者实测
12
- // `curl -C -` 能稳定完成,说明服务端支持 Range。整条链都在本地 http 服务器上测:
13
- // 断流、续传、退避重试、进度百分比 —— 不依赖外网。
14
-
15
- /** 内容有变化的字节(0..250 循环):任何丢块 / 重复块都会在逐字节比对里现形。 */
16
- function makeBody(size) {
17
- const body = Buffer.alloc(size);
18
- for (let i = 0; i < size; i++) body[i] = i % 251;
19
- return body;
20
- }
21
-
22
- /**
23
- * 只服务内存里那份字节的服务器。
24
- * - `cutFirst`:该 URL 的首次请求只发一半就掐断(等 30ms 再 destroy,保证客户端真的收到过字节);
25
- * - `ignoreRange`:无视 Range 头,永远从 0 回全量 200 —— 逼出「续传被拒就从头再来」;
26
- * - `seen` 记录收到的 Range 头与请求数。
27
- */
28
- async function serve(files, { cutFirst = null, ignoreRange = false } = {}) {
29
- const seen = { rangeHeader: null, requests: 0 };
30
- let cutDone = false;
31
- const server = createServer((req, res) => {
32
- seen.requests++;
33
- const body = files[req.url];
34
- if (body === undefined) {
35
- res.writeHead(404).end();
36
- return;
37
- }
38
- const range = req.headers.range;
39
- if (typeof range === "string") seen.rangeHeader = range;
40
- const start = ignoreRange ? 0 : Number(/bytes=(\d+)-/.exec(range ?? "")?.[1] ?? 0);
41
-
42
- if (cutFirst === req.url && !cutDone && start === 0) {
43
- cutDone = true;
44
- res.writeHead(200, {
45
- "Content-Length": String(body.length),
46
- ETag: '"immutable-etag"'
47
- });
48
- res.write(body.subarray(0, Math.floor(body.length / 2)));
49
- // 立刻 destroy 会在客户端读到任何字节之前就断掉,那样就测不到「带着已收字节续传」。
50
- setTimeout(() => res.destroy(), 30);
51
- return;
52
- }
53
- if (start > 0) {
54
- const rest = body.subarray(start);
55
- res.writeHead(206, {
56
- "Content-Range": `bytes ${start}-${body.length - 1}/${body.length}`,
57
- ETag: '"immutable-etag"'
58
- });
59
- res.end(rest);
60
- return;
61
- }
62
- res.writeHead(200, { "Content-Length": String(body.length), ETag: '"immutable-etag"' });
63
- res.end(body);
64
- });
65
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
66
- const base = `http://127.0.0.1:${server.address().port}`;
67
- return { server, base, seen, close: () => new Promise((resolve) => server.close(resolve)) };
68
- }
69
-
70
- function partDirFor() {
71
- return mkdtempSync(join(tmpdir(), "mneme-mfetch-"));
72
- }
73
-
74
- function testFetcher(partDir, opts = {}) {
75
- return createResilientFetch({ partDir, retryDelayMs: 1, idleTimeoutMs: 5000, ...opts });
76
- }
77
-
78
- test("model-fetch:中途 terminated 后用 Range 续传,最终字节与源逐字节一致", async () => {
79
- const body = makeBody(65536);
80
- const srv = await serve({ "/model.onnx": body }, { cutFirst: "/model.onnx" });
81
- const dir = partDirFor();
82
- try {
83
- const fetchImpl = testFetcher(dir);
84
- const res = await fetchImpl(`${srv.base}/model.onnx`);
85
- assert.equal(res.status, 200, "对 transformers.js 必须以 200 形态出现(hub 会把非 200 当失败)");
86
- const received = Buffer.from(await res.arrayBuffer());
87
- assert.ok(received.equals(body), "续传拼出的字节必须与源完全一致");
88
- // 关键证据:第二次请求带了 Range —— 是续传,不是从头再来。
89
- assert.match(String(srv.seen.rangeHeader), /^bytes=\d+-$/, `续传请求应当带 Range,实际=${srv.seen.rangeHeader}`);
90
- } finally {
91
- await srv.close();
92
- }
93
- });
94
-
95
- test("model-fetch:每次失败都带中断百分比;重试耗尽时错误信息说清断在哪", async () => {
96
- const body = makeBody(100000);
97
- // 服务器掐在固定位置:每次请求都只发 30% 就断。
98
- const server = createServer((_req, res) => {
99
- res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
100
- res.write(body.subarray(0, 30000));
101
- setTimeout(() => res.destroy(), 20);
102
- });
103
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
104
- const base = `http://127.0.0.1:${server.address().port}`;
105
- const dir = partDirFor();
106
- try {
107
- const events = [];
108
- const fetchImpl = testFetcher(dir, { attempts: 2, onEvent: (e) => events.push(e) });
109
- await assert.rejects(
110
- () => fetchImpl(`${base}/big.bin`),
111
- /中断于|30%/,
112
- "最终错误必须带中断位置,让用户能区分网络问题与磁盘问题"
113
- );
114
- const fail = events.filter((e) => e.type === "fail");
115
- assert.ok(fail.length >= 1, "每次失败都应发 fail 事件");
116
- assert.equal(fail.at(-1).percent, 30, `断在 30%%(30000/100000),实际=${fail.at(-1).percent}`);
117
- } finally {
118
- await new Promise((resolve) => server.close(resolve));
119
- }
120
- });
121
-
122
- test("model-fetch:If-Range 失配(服务端内容已换)时回 200,断点作废整份重来,不产出拼接损坏", async () => {
123
- const v1 = makeBody(65536);
124
- const v2 = Buffer.from(v1.map((b) => (b + 7) % 251));
125
- let broken = false;
126
- const server = createServer((req, res) => {
127
- const range = Number(/bytes=(\d+)-/.exec(req.headers.range ?? "")?.[1] ?? 0);
128
- // If-Range 与当前内容不符(或没带):真实服务器会回 200 全量 —— 内容换过了。
129
- if (req.headers["if-range"] !== undefined && req.headers["if-range"] !== '"v2"') {
130
- broken = true;
131
- res.writeHead(200, { "Content-Length": String(v2.length), ETag: '"v2"' });
132
- res.end(v2);
133
- return;
134
- }
135
- if (range > 0 && !broken) {
136
- res.writeHead(206, {
137
- "Content-Range": `bytes ${range}-${v1.length - 1}/${v1.length}`,
138
- ETag: '"v1"'
139
- });
140
- res.end(v1.subarray(range));
141
- return;
142
- }
143
- if (!broken) {
144
- res.writeHead(200, { "Content-Length": String(v1.length), ETag: '"v1"' });
145
- res.write(v1.subarray(0, Math.floor(v1.length / 2)));
146
- setTimeout(() => res.destroy(), 30);
147
- return;
148
- }
149
- });
150
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
151
- const base = `http://127.0.0.1:${server.address().port}`;
152
- const dir = partDirFor();
153
- try {
154
- const events = [];
155
- const fetchImpl = testFetcher(dir, { attempts: 2, onEvent: (e) => events.push(e) });
156
- const res = await fetchImpl(`${base}/model.onnx`);
157
- const body = Buffer.from(await res.arrayBuffer());
158
- assert.ok(events.some((e) => e.type === "restart" && e.reason === "range-rejected"), "应发 range-rejected 重来事件");
159
- assert.ok(body.equals(v2), "最终字节必须是新内容整份,而不是 v1 断点拼 v2 的损坏物");
160
- } finally {
161
- await new Promise((resolve) => server.close(resolve));
162
- }
163
- });
164
-
165
- test("model-fetch:服务器续传位置与断点对不上时清掉断点重来(拼错比失败更糟)", async () => {
166
- const body = makeBody(65536);
167
- let cutDone = false;
168
- const server = createServer((req, res) => {
169
- const range = req.headers.range;
170
- if (typeof range === "string") {
171
- // 故意回一个与断点无关的 206(从 0 开始):客户端必须识别出偏移不一致。
172
- res.writeHead(206, { "Content-Range": `bytes 0-${body.length - 1}/${body.length}`, ETag: '"x"' });
173
- res.end(body);
174
- return;
175
- }
176
- if (!cutDone) {
177
- cutDone = true;
178
- res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
179
- res.write(body.subarray(0, 30000));
180
- setTimeout(() => res.destroy(), 20);
181
- return;
182
- }
183
- res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
184
- res.end(body);
185
- });
186
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
187
- const base = `http://127.0.0.1:${server.address().port}`;
188
- const dir = partDirFor();
189
- try {
190
- const events = [];
191
- const fetchImpl = testFetcher(dir, { attempts: 3, onEvent: (e) => events.push(e) });
192
- const res = await fetchImpl(`${base}/model.onnx`);
193
- const received = Buffer.from(await res.arrayBuffer());
194
- assert.ok(events.some((e) => e.type === "restart" && e.reason === "offset-mismatch"));
195
- assert.ok(received.equals(body), "重置后重新下载的整份字节必须正确");
196
- } finally {
197
- await new Promise((resolve) => server.close(resolve));
198
- }
199
- });
200
-
201
- test("model-fetch:416(断点越过文件末尾)时重置断点重试,而不是把重试次数烧完", async () => {
202
- const body = makeBody(65536);
203
- const server = createServer((req, res) => {
204
- const start = Number(/bytes=(\d+)-/.exec(req.headers.range ?? "")?.[1] ?? -1);
205
- if (start >= body.length) {
206
- // 诚实的 416:Range 起点越过源文件末尾(断点来自被替换前的旧文件)。
207
- res.writeHead(416, { "Content-Range": `bytes */${body.length}` });
208
- res.end();
209
- return;
210
- }
211
- if (start > 0) {
212
- res.writeHead(206, { "Content-Range": `bytes ${start}-${body.length - 1}/${body.length}`, ETag: '"x"' });
213
- res.end(body.subarray(start));
214
- return;
215
- }
216
- res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
217
- res.end(body);
218
- });
219
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
220
- const base = `http://127.0.0.1:${server.address().port}`;
221
- const url = `${base}/model.onnx`;
222
- const dir = partDirFor();
223
- try {
224
- // 预置一份比源文件还大的断点(70000 > 65536)+ 它的 etag。
225
- const partPath = join(dir, createHash("sha256").update(url).digest("hex"));
226
- writeFileSync(partPath, Buffer.alloc(70000, 1));
227
- writeFileSync(`${partPath}.meta`, '"old"\n');
228
- const events = [];
229
- const fetchImpl = testFetcher(dir, { attempts: 2, onEvent: (e) => events.push(e) });
230
- const res = await fetchImpl(url);
231
- const received = Buffer.from(await res.arrayBuffer());
232
- assert.ok(events.some((e) => e.type === "restart" && e.reason === "range-not-satisfiable"));
233
- assert.ok(received.equals(body), "重置后重新下载的整份字节必须正确");
234
- } finally {
235
- await new Promise((resolve) => server.close(resolve));
236
- }
237
- });
238
-
239
- test("model-fetch:连接僵死时被空闲看门狗掐断并重试,而不是永远挂着", async () => {
240
- const body = makeBody(65536);
241
- let stalled = false;
242
- const server = createServer((_req, res) => {
243
- if (!stalled) {
244
- stalled = true;
245
- res.writeHead(200, { "Content-Length": String(body.length) });
246
- res.write(body.subarray(0, 10));
247
- return; // 永远不 end:僵死的 socket。
248
- }
249
- res.writeHead(200, { "Content-Length": String(body.length) });
250
- res.end(body);
251
- });
252
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
253
- const base = `http://127.0.0.1:${server.address().port}`;
254
- const dir = partDirFor();
255
- try {
256
- const events = [];
257
- const started = Date.now();
258
- const fetchImpl = testFetcher(dir, { attempts: 2, idleTimeoutMs: 150, retryDelayMs: 1, onEvent: (e) => events.push(e) });
259
- const res = await fetchImpl(`${base}/model.onnx`);
260
- const received = Buffer.from(await res.arrayBuffer());
261
- assert.ok(Date.now() - started < 5000, "应当被看门狗及时掐断,而不是挂住");
262
- assert.ok(events.some((e) => e.type === "fail" && /空闲/.test(e.error)), `失败事件应写明空闲超时,实际=${events.map((e) => e.error)}`);
263
- assert.ok(received.equals(body));
264
- } finally {
265
- server.closeAllConnections();
266
- await new Promise((resolve) => server.close(resolve));
267
- }
268
- });
269
-
270
- test("model-fetch:服务端干净地提前关流(字节没给够)被查出并续传补齐", async () => {
271
- const body = makeBody(100000);
272
- const server = createServer((req, res) => {
273
- const range = Number(/bytes=(\d+)-/.exec(req.headers.range ?? "")?.[1] ?? 0);
274
- if (range > 0) {
275
- res.writeHead(206, { "Content-Range": `bytes ${range}-${body.length - 1}/${body.length}`, ETag: '"x"' });
276
- res.end(body.subarray(range));
277
- return;
278
- }
279
- res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
280
- res.end(body.subarray(0, 30000)); // 声明 100000 只给 30000,然后"正常"结束。
281
- });
282
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
283
- const base = `http://127.0.0.1:${server.address().port}`;
284
- const dir = partDirFor();
285
- try {
286
- const fetchImpl = testFetcher(dir, { attempts: 2 });
287
- const res = await fetchImpl(`${base}/model.onnx`);
288
- const received = Buffer.from(await res.arrayBuffer());
289
- assert.ok(received.equals(body), "缺的字节要从断点补齐");
290
- } finally {
291
- await new Promise((resolve) => server.close(resolve));
292
- }
293
- });
294
-
295
- test("model-fetch:非 GET 与抢不到锁的请求原样直通,不落盘、不带 Range", async () => {
296
- const dir = partDirFor();
297
- const calls = [];
298
- const stub = (input, init) => {
299
- calls.push({ input: String(input), init: init ?? null });
300
- return new Response("raw", { status: 200, headers: { "content-length": "3" } });
301
- };
302
- const fetchImpl = testFetcher(dir, { fetchImpl: stub });
303
- await fetchImpl("https://example.com/meta", { method: "HEAD" });
304
- // 锁名与被测实现一致:<sha256(url)>.lock;新鲜的锁 = 别的进程正在写。
305
- const url = "https://example.com/model.onnx";
306
- const locked = join(dir, `${createHash("sha256").update(url).digest("hex")}.lock`);
307
- writeFileSync(locked, "9999");
308
- await fetchImpl(url);
309
- assert.equal(calls.length, 2, "两个请求都应直通底层 fetch");
310
- assert.equal(calls[0].init.method, "HEAD");
311
- assert.equal(calls[1].init, null, "直通不应改写调用参数");
312
- assert.deepEqual(readdirSync(dir), [`${createHash("sha256").update(url).digest("hex")}.lock`], "直通不该产生断点文件");
313
- rmSync(locked, { force: true });
314
- });
315
-
316
- test("model-fetch:响应体消费完后断点文件、meta 与锁都清理干净", async () => {
317
- const body = makeBody(65536);
318
- const srv = await serve({ "/model.onnx": body }, { cutFirst: "/model.onnx" });
319
- const dir = partDirFor();
320
- try {
321
- const fetchImpl = testFetcher(dir);
322
- const res = await fetchImpl(`${srv.base}/model.onnx`);
323
- await res.arrayBuffer(); // 消费完 = 下游(FileCache.put)拿完字节了。
324
- await new Promise((resolve) => setImmediate(resolve));
325
- assert.equal(readdirSync(dir).length, 0, `断点目录应已清空,实际=${readdirSync(dir)}`);
326
- } finally {
327
- await srv.close();
328
- }
329
- });
330
-
331
- test("installResilientFetch:开关关 = env 一字不动;开 = env.fetch 换成弹性实现", async () => {
332
- const envOff = {};
333
- assert.equal(installResilientFetch(envOff, { enabled: false }), false);
334
- assert.equal(envOff.fetch, undefined, "关闭时不许碰 env(默认保持现状的出口)");
335
- assert.equal(installResilientFetch(null, { enabled: true }), false, "env 缺席时不安装");
336
-
337
- const cacheRoot = mkdtempSync(join(tmpdir(), "mneme-install-"));
338
- const envOn = {};
339
- assert.equal(installResilientFetch(envOn, { enabled: true, cacheDir: cacheRoot, fetchImpl: () => new Response("x") }), true);
340
- assert.equal(typeof envOn.fetch, "function");
341
- assert.ok(existsSync(join(cacheRoot, ".mneme-partial")), "断点目录建在模型缓存旁");
342
-
343
- // 装上的 fetch 是能干活的:小文件直通下载 + 返回规范 200。
344
- const res = await envOn.fetch("https://example.com/tiny.bin");
345
- assert.equal(res.status, 200);
346
- assert.equal(await res.text(), "x");
347
- });
348
-
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createServer } from "node:http";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { createResilientFetch, installResilientFetch } from "../src/runtime/model-fetch.js";
9
+
10
+ // 模型文件取件层(issue #194)。transformers.js 下载几百 MB~1GB 的模型文件时,
11
+ // undici socket 中途被重置(TypeError: terminated)会让整份下载作废重来;报告者实测
12
+ // `curl -C -` 能稳定完成,说明服务端支持 Range。整条链都在本地 http 服务器上测:
13
+ // 断流、续传、退避重试、进度百分比 —— 不依赖外网。
14
+
15
+ /** 内容有变化的字节(0..250 循环):任何丢块 / 重复块都会在逐字节比对里现形。 */
16
+ function makeBody(size) {
17
+ const body = Buffer.alloc(size);
18
+ for (let i = 0; i < size; i++) body[i] = i % 251;
19
+ return body;
20
+ }
21
+
22
+ /**
23
+ * 只服务内存里那份字节的服务器。
24
+ * - `cutFirst`:该 URL 的首次请求只发一半就掐断(等 30ms 再 destroy,保证客户端真的收到过字节);
25
+ * - `ignoreRange`:无视 Range 头,永远从 0 回全量 200 —— 逼出「续传被拒就从头再来」;
26
+ * - `seen` 记录收到的 Range 头与请求数。
27
+ */
28
+ async function serve(files, { cutFirst = null, ignoreRange = false } = {}) {
29
+ const seen = { rangeHeader: null, requests: 0 };
30
+ let cutDone = false;
31
+ const server = createServer((req, res) => {
32
+ seen.requests++;
33
+ const body = files[req.url];
34
+ if (body === undefined) {
35
+ res.writeHead(404).end();
36
+ return;
37
+ }
38
+ const range = req.headers.range;
39
+ if (typeof range === "string") seen.rangeHeader = range;
40
+ const start = ignoreRange ? 0 : Number(/bytes=(\d+)-/.exec(range ?? "")?.[1] ?? 0);
41
+
42
+ if (cutFirst === req.url && !cutDone && start === 0) {
43
+ cutDone = true;
44
+ res.writeHead(200, {
45
+ "Content-Length": String(body.length),
46
+ ETag: '"immutable-etag"'
47
+ });
48
+ res.write(body.subarray(0, Math.floor(body.length / 2)));
49
+ // 立刻 destroy 会在客户端读到任何字节之前就断掉,那样就测不到「带着已收字节续传」。
50
+ setTimeout(() => res.destroy(), 30);
51
+ return;
52
+ }
53
+ if (start > 0) {
54
+ const rest = body.subarray(start);
55
+ res.writeHead(206, {
56
+ "Content-Range": `bytes ${start}-${body.length - 1}/${body.length}`,
57
+ ETag: '"immutable-etag"'
58
+ });
59
+ res.end(rest);
60
+ return;
61
+ }
62
+ res.writeHead(200, { "Content-Length": String(body.length), ETag: '"immutable-etag"' });
63
+ res.end(body);
64
+ });
65
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
66
+ const base = `http://127.0.0.1:${server.address().port}`;
67
+ return { server, base, seen, close: () => new Promise((resolve) => server.close(resolve)) };
68
+ }
69
+
70
+ function partDirFor() {
71
+ return mkdtempSync(join(tmpdir(), "mneme-mfetch-"));
72
+ }
73
+
74
+ function testFetcher(partDir, opts = {}) {
75
+ return createResilientFetch({ partDir, retryDelayMs: 1, idleTimeoutMs: 5000, ...opts });
76
+ }
77
+
78
+ test("model-fetch:中途 terminated 后用 Range 续传,最终字节与源逐字节一致", async () => {
79
+ const body = makeBody(65536);
80
+ const srv = await serve({ "/model.onnx": body }, { cutFirst: "/model.onnx" });
81
+ const dir = partDirFor();
82
+ try {
83
+ const fetchImpl = testFetcher(dir);
84
+ const res = await fetchImpl(`${srv.base}/model.onnx`);
85
+ assert.equal(res.status, 200, "对 transformers.js 必须以 200 形态出现(hub 会把非 200 当失败)");
86
+ const received = Buffer.from(await res.arrayBuffer());
87
+ assert.ok(received.equals(body), "续传拼出的字节必须与源完全一致");
88
+ // 关键证据:第二次请求带了 Range —— 是续传,不是从头再来。
89
+ assert.match(String(srv.seen.rangeHeader), /^bytes=\d+-$/, `续传请求应当带 Range,实际=${srv.seen.rangeHeader}`);
90
+ } finally {
91
+ await srv.close();
92
+ }
93
+ });
94
+
95
+ test("model-fetch:每次失败都带中断百分比;重试耗尽时错误信息说清断在哪", async () => {
96
+ const body = makeBody(100000);
97
+ // 服务器掐在固定位置:每次请求都只发 30% 就断。
98
+ const server = createServer((_req, res) => {
99
+ res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
100
+ res.write(body.subarray(0, 30000));
101
+ setTimeout(() => res.destroy(), 20);
102
+ });
103
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
104
+ const base = `http://127.0.0.1:${server.address().port}`;
105
+ const dir = partDirFor();
106
+ try {
107
+ const events = [];
108
+ const fetchImpl = testFetcher(dir, { attempts: 2, onEvent: (e) => events.push(e) });
109
+ await assert.rejects(
110
+ () => fetchImpl(`${base}/big.bin`),
111
+ /中断于|30%/,
112
+ "最终错误必须带中断位置,让用户能区分网络问题与磁盘问题"
113
+ );
114
+ const fail = events.filter((e) => e.type === "fail");
115
+ assert.ok(fail.length >= 1, "每次失败都应发 fail 事件");
116
+ assert.equal(fail.at(-1).percent, 30, `断在 30%%(30000/100000),实际=${fail.at(-1).percent}`);
117
+ } finally {
118
+ await new Promise((resolve) => server.close(resolve));
119
+ }
120
+ });
121
+
122
+ test("model-fetch:If-Range 失配(服务端内容已换)时回 200,断点作废整份重来,不产出拼接损坏", async () => {
123
+ const v1 = makeBody(65536);
124
+ const v2 = Buffer.from(v1.map((b) => (b + 7) % 251));
125
+ let broken = false;
126
+ const server = createServer((req, res) => {
127
+ const range = Number(/bytes=(\d+)-/.exec(req.headers.range ?? "")?.[1] ?? 0);
128
+ // If-Range 与当前内容不符(或没带):真实服务器会回 200 全量 —— 内容换过了。
129
+ if (req.headers["if-range"] !== undefined && req.headers["if-range"] !== '"v2"') {
130
+ broken = true;
131
+ res.writeHead(200, { "Content-Length": String(v2.length), ETag: '"v2"' });
132
+ res.end(v2);
133
+ return;
134
+ }
135
+ if (range > 0 && !broken) {
136
+ res.writeHead(206, {
137
+ "Content-Range": `bytes ${range}-${v1.length - 1}/${v1.length}`,
138
+ ETag: '"v1"'
139
+ });
140
+ res.end(v1.subarray(range));
141
+ return;
142
+ }
143
+ if (!broken) {
144
+ res.writeHead(200, { "Content-Length": String(v1.length), ETag: '"v1"' });
145
+ res.write(v1.subarray(0, Math.floor(v1.length / 2)));
146
+ setTimeout(() => res.destroy(), 30);
147
+ return;
148
+ }
149
+ });
150
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
151
+ const base = `http://127.0.0.1:${server.address().port}`;
152
+ const dir = partDirFor();
153
+ try {
154
+ const events = [];
155
+ const fetchImpl = testFetcher(dir, { attempts: 2, onEvent: (e) => events.push(e) });
156
+ const res = await fetchImpl(`${base}/model.onnx`);
157
+ const body = Buffer.from(await res.arrayBuffer());
158
+ assert.ok(events.some((e) => e.type === "restart" && e.reason === "range-rejected"), "应发 range-rejected 重来事件");
159
+ assert.ok(body.equals(v2), "最终字节必须是新内容整份,而不是 v1 断点拼 v2 的损坏物");
160
+ } finally {
161
+ await new Promise((resolve) => server.close(resolve));
162
+ }
163
+ });
164
+
165
+ test("model-fetch:服务器续传位置与断点对不上时清掉断点重来(拼错比失败更糟)", async () => {
166
+ const body = makeBody(65536);
167
+ let cutDone = false;
168
+ const server = createServer((req, res) => {
169
+ const range = req.headers.range;
170
+ if (typeof range === "string") {
171
+ // 故意回一个与断点无关的 206(从 0 开始):客户端必须识别出偏移不一致。
172
+ res.writeHead(206, { "Content-Range": `bytes 0-${body.length - 1}/${body.length}`, ETag: '"x"' });
173
+ res.end(body);
174
+ return;
175
+ }
176
+ if (!cutDone) {
177
+ cutDone = true;
178
+ res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
179
+ res.write(body.subarray(0, 30000));
180
+ setTimeout(() => res.destroy(), 20);
181
+ return;
182
+ }
183
+ res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
184
+ res.end(body);
185
+ });
186
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
187
+ const base = `http://127.0.0.1:${server.address().port}`;
188
+ const dir = partDirFor();
189
+ try {
190
+ const events = [];
191
+ const fetchImpl = testFetcher(dir, { attempts: 3, onEvent: (e) => events.push(e) });
192
+ const res = await fetchImpl(`${base}/model.onnx`);
193
+ const received = Buffer.from(await res.arrayBuffer());
194
+ assert.ok(events.some((e) => e.type === "restart" && e.reason === "offset-mismatch"));
195
+ assert.ok(received.equals(body), "重置后重新下载的整份字节必须正确");
196
+ } finally {
197
+ await new Promise((resolve) => server.close(resolve));
198
+ }
199
+ });
200
+
201
+ test("model-fetch:416(断点越过文件末尾)时重置断点重试,而不是把重试次数烧完", async () => {
202
+ const body = makeBody(65536);
203
+ const server = createServer((req, res) => {
204
+ const start = Number(/bytes=(\d+)-/.exec(req.headers.range ?? "")?.[1] ?? -1);
205
+ if (start >= body.length) {
206
+ // 诚实的 416:Range 起点越过源文件末尾(断点来自被替换前的旧文件)。
207
+ res.writeHead(416, { "Content-Range": `bytes */${body.length}` });
208
+ res.end();
209
+ return;
210
+ }
211
+ if (start > 0) {
212
+ res.writeHead(206, { "Content-Range": `bytes ${start}-${body.length - 1}/${body.length}`, ETag: '"x"' });
213
+ res.end(body.subarray(start));
214
+ return;
215
+ }
216
+ res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
217
+ res.end(body);
218
+ });
219
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
220
+ const base = `http://127.0.0.1:${server.address().port}`;
221
+ const url = `${base}/model.onnx`;
222
+ const dir = partDirFor();
223
+ try {
224
+ // 预置一份比源文件还大的断点(70000 > 65536)+ 它的 etag。
225
+ const partPath = join(dir, createHash("sha256").update(url).digest("hex"));
226
+ writeFileSync(partPath, Buffer.alloc(70000, 1));
227
+ writeFileSync(`${partPath}.meta`, '"old"\n');
228
+ const events = [];
229
+ const fetchImpl = testFetcher(dir, { attempts: 2, onEvent: (e) => events.push(e) });
230
+ const res = await fetchImpl(url);
231
+ const received = Buffer.from(await res.arrayBuffer());
232
+ assert.ok(events.some((e) => e.type === "restart" && e.reason === "range-not-satisfiable"));
233
+ assert.ok(received.equals(body), "重置后重新下载的整份字节必须正确");
234
+ } finally {
235
+ await new Promise((resolve) => server.close(resolve));
236
+ }
237
+ });
238
+
239
+ test("model-fetch:连接僵死时被空闲看门狗掐断并重试,而不是永远挂着", async () => {
240
+ const body = makeBody(65536);
241
+ let stalled = false;
242
+ const server = createServer((_req, res) => {
243
+ if (!stalled) {
244
+ stalled = true;
245
+ res.writeHead(200, { "Content-Length": String(body.length) });
246
+ res.write(body.subarray(0, 10));
247
+ return; // 永远不 end:僵死的 socket。
248
+ }
249
+ res.writeHead(200, { "Content-Length": String(body.length) });
250
+ res.end(body);
251
+ });
252
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
253
+ const base = `http://127.0.0.1:${server.address().port}`;
254
+ const dir = partDirFor();
255
+ try {
256
+ const events = [];
257
+ const started = Date.now();
258
+ const fetchImpl = testFetcher(dir, { attempts: 2, idleTimeoutMs: 150, retryDelayMs: 1, onEvent: (e) => events.push(e) });
259
+ const res = await fetchImpl(`${base}/model.onnx`);
260
+ const received = Buffer.from(await res.arrayBuffer());
261
+ assert.ok(Date.now() - started < 5000, "应当被看门狗及时掐断,而不是挂住");
262
+ assert.ok(events.some((e) => e.type === "fail" && /空闲/.test(e.error)), `失败事件应写明空闲超时,实际=${events.map((e) => e.error)}`);
263
+ assert.ok(received.equals(body));
264
+ } finally {
265
+ server.closeAllConnections();
266
+ await new Promise((resolve) => server.close(resolve));
267
+ }
268
+ });
269
+
270
+ test("model-fetch:服务端干净地提前关流(字节没给够)被查出并续传补齐", async () => {
271
+ const body = makeBody(100000);
272
+ const server = createServer((req, res) => {
273
+ const range = Number(/bytes=(\d+)-/.exec(req.headers.range ?? "")?.[1] ?? 0);
274
+ if (range > 0) {
275
+ res.writeHead(206, { "Content-Range": `bytes ${range}-${body.length - 1}/${body.length}`, ETag: '"x"' });
276
+ res.end(body.subarray(range));
277
+ return;
278
+ }
279
+ res.writeHead(200, { "Content-Length": String(body.length), ETag: '"x"' });
280
+ res.end(body.subarray(0, 30000)); // 声明 100000 只给 30000,然后"正常"结束。
281
+ });
282
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
283
+ const base = `http://127.0.0.1:${server.address().port}`;
284
+ const dir = partDirFor();
285
+ try {
286
+ const fetchImpl = testFetcher(dir, { attempts: 2 });
287
+ const res = await fetchImpl(`${base}/model.onnx`);
288
+ const received = Buffer.from(await res.arrayBuffer());
289
+ assert.ok(received.equals(body), "缺的字节要从断点补齐");
290
+ } finally {
291
+ await new Promise((resolve) => server.close(resolve));
292
+ }
293
+ });
294
+
295
+ test("model-fetch:非 GET 与抢不到锁的请求原样直通,不落盘、不带 Range", async () => {
296
+ const dir = partDirFor();
297
+ const calls = [];
298
+ const stub = (input, init) => {
299
+ calls.push({ input: String(input), init: init ?? null });
300
+ return new Response("raw", { status: 200, headers: { "content-length": "3" } });
301
+ };
302
+ const fetchImpl = testFetcher(dir, { fetchImpl: stub });
303
+ await fetchImpl("https://example.com/meta", { method: "HEAD" });
304
+ // 锁名与被测实现一致:<sha256(url)>.lock;新鲜的锁 = 别的进程正在写。
305
+ const url = "https://example.com/model.onnx";
306
+ const locked = join(dir, `${createHash("sha256").update(url).digest("hex")}.lock`);
307
+ writeFileSync(locked, "9999");
308
+ await fetchImpl(url);
309
+ assert.equal(calls.length, 2, "两个请求都应直通底层 fetch");
310
+ assert.equal(calls[0].init.method, "HEAD");
311
+ assert.equal(calls[1].init, null, "直通不应改写调用参数");
312
+ assert.deepEqual(readdirSync(dir), [`${createHash("sha256").update(url).digest("hex")}.lock`], "直通不该产生断点文件");
313
+ rmSync(locked, { force: true });
314
+ });
315
+
316
+ test("model-fetch:响应体消费完后断点文件、meta 与锁都清理干净", async () => {
317
+ const body = makeBody(65536);
318
+ const srv = await serve({ "/model.onnx": body }, { cutFirst: "/model.onnx" });
319
+ const dir = partDirFor();
320
+ try {
321
+ const fetchImpl = testFetcher(dir);
322
+ const res = await fetchImpl(`${srv.base}/model.onnx`);
323
+ await res.arrayBuffer(); // 消费完 = 下游(FileCache.put)拿完字节了。
324
+ await new Promise((resolve) => setImmediate(resolve));
325
+ assert.equal(readdirSync(dir).length, 0, `断点目录应已清空,实际=${readdirSync(dir)}`);
326
+ } finally {
327
+ await srv.close();
328
+ }
329
+ });
330
+
331
+ test("installResilientFetch:开关关 = env 一字不动;开 = env.fetch 换成弹性实现", async () => {
332
+ const envOff = {};
333
+ assert.equal(installResilientFetch(envOff, { enabled: false }), false);
334
+ assert.equal(envOff.fetch, undefined, "关闭时不许碰 env(默认保持现状的出口)");
335
+ assert.equal(installResilientFetch(null, { enabled: true }), false, "env 缺席时不安装");
336
+
337
+ const cacheRoot = mkdtempSync(join(tmpdir(), "mneme-install-"));
338
+ const envOn = {};
339
+ assert.equal(installResilientFetch(envOn, { enabled: true, cacheDir: cacheRoot, fetchImpl: () => new Response("x") }), true);
340
+ assert.equal(typeof envOn.fetch, "function");
341
+ assert.ok(existsSync(join(cacheRoot, ".mneme-partial")), "断点目录建在模型缓存旁");
342
+
343
+ // 装上的 fetch 是能干活的:小文件直通下载 + 返回规范 200。
344
+ const res = await envOn.fetch("https://example.com/tiny.bin");
345
+ assert.equal(res.status, 200);
346
+ assert.equal(await res.text(), "x");
347
+ });
348
+