@avantf/dsh-plugin-base 0.3.1 → 0.4.0

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 (98) hide show
  1. package/README.md +29 -15
  2. package/dist/bootstrap.d.ts +3 -2
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +3 -2
  5. package/dist/compat.d.ts +2 -2
  6. package/dist/compat.js +5 -5
  7. package/dist/errors.d.ts +6 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/index.d.ts +23 -9
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +45 -22
  12. package/dist/interface.d.ts +296 -23
  13. package/dist/interface.d.ts.map +1 -1
  14. package/dist/interface.js +206 -8
  15. package/dist/interface_gate.d.ts +20 -6
  16. package/dist/interface_gate.d.ts.map +1 -1
  17. package/dist/interface_gate.js +37 -7
  18. package/dist/internal.d.ts +26 -1
  19. package/dist/internal.d.ts.map +1 -1
  20. package/dist/internal.js +27 -1
  21. package/dist/kit/family.d.ts +2 -2
  22. package/dist/kit/family.js +3 -3
  23. package/dist/kit/index.d.ts +5 -1
  24. package/dist/kit/index.d.ts.map +1 -1
  25. package/dist/kit/index.js +5 -1
  26. package/dist/kit/logger.d.ts +1 -1
  27. package/dist/kit/logger.js +1 -1
  28. package/dist/kit/prompt_files.d.ts +37 -3
  29. package/dist/kit/prompt_files.d.ts.map +1 -1
  30. package/dist/kit/prompt_files.js +45 -0
  31. package/dist/kit/typert.d.ts +2 -2
  32. package/dist/kit/wellformed.d.ts +79 -0
  33. package/dist/kit/wellformed.d.ts.map +1 -0
  34. package/dist/kit/wellformed.js +155 -0
  35. package/dist/layout.d.ts +17 -2
  36. package/dist/layout.d.ts.map +1 -1
  37. package/dist/layout.js +15 -5
  38. package/dist/lint.d.ts.map +1 -1
  39. package/dist/lint.js +2 -2
  40. package/dist/lock.d.ts +18 -0
  41. package/dist/lock.d.ts.map +1 -1
  42. package/dist/lock.js +77 -10
  43. package/dist/net.d.ts +106 -3
  44. package/dist/net.d.ts.map +1 -1
  45. package/dist/net.js +128 -30
  46. package/dist/providers/archive.d.ts.map +1 -1
  47. package/dist/providers/archive.js +108 -20
  48. package/dist/providers/model.d.ts +26 -3
  49. package/dist/providers/model.d.ts.map +1 -1
  50. package/dist/providers/model.js +159 -32
  51. package/dist/providers/npm.d.ts +5 -0
  52. package/dist/providers/npm.d.ts.map +1 -1
  53. package/dist/providers/npm.js +62 -21
  54. package/dist/provisioner.d.ts.map +1 -1
  55. package/dist/provisioner.js +128 -31
  56. package/dist/state.d.ts.map +1 -1
  57. package/dist/state.js +8 -3
  58. package/dist/tar.d.ts +7 -0
  59. package/dist/tar.d.ts.map +1 -1
  60. package/dist/tar.js +12 -5
  61. package/dist/types.d.ts +11 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/zip.d.ts.map +1 -1
  64. package/dist/zip.js +10 -1
  65. package/package.json +18 -19
  66. package/dist/artifact.js.map +0 -1
  67. package/dist/bootstrap.js.map +0 -1
  68. package/dist/cli.js.map +0 -1
  69. package/dist/compat.js.map +0 -1
  70. package/dist/conformance.js.map +0 -1
  71. package/dist/errors.js.map +0 -1
  72. package/dist/fs.js.map +0 -1
  73. package/dist/index.js.map +0 -1
  74. package/dist/integrity.js.map +0 -1
  75. package/dist/interface.js.map +0 -1
  76. package/dist/interface_gate.js.map +0 -1
  77. package/dist/internal.js.map +0 -1
  78. package/dist/kit/family.js.map +0 -1
  79. package/dist/kit/index.js.map +0 -1
  80. package/dist/kit/logger.js.map +0 -1
  81. package/dist/kit/prompt_files.js.map +0 -1
  82. package/dist/kit/typert.js.map +0 -1
  83. package/dist/layout.js.map +0 -1
  84. package/dist/lint.js.map +0 -1
  85. package/dist/lock.js.map +0 -1
  86. package/dist/manifest.js.map +0 -1
  87. package/dist/net.js.map +0 -1
  88. package/dist/package-name.js.map +0 -1
  89. package/dist/preset.js.map +0 -1
  90. package/dist/providers/archive.js.map +0 -1
  91. package/dist/providers/model.js.map +0 -1
  92. package/dist/providers/npm.js.map +0 -1
  93. package/dist/provisioner.js.map +0 -1
  94. package/dist/semver.js.map +0 -1
  95. package/dist/state.js.map +0 -1
  96. package/dist/tar.js.map +0 -1
  97. package/dist/types.js.map +0 -1
  98. package/dist/zip.js.map +0 -1
package/dist/net.d.ts CHANGED
@@ -3,15 +3,118 @@ import type { ProgressEvent, ProviderContext } from './types.js';
3
3
  export declare function fetchImplOf(ctx: ProviderContext): typeof fetch;
4
4
  /** Default hard byte cap for one downloaded body. */
5
5
  export declare const DEFAULT_MAX_BYTES: number;
6
+ /**
7
+ * Hard byte cap for a METADATA body: an npm packument, a model revision response, a siblings list.
8
+ *
9
+ * Metadata is a small JSON document that is then `JSON.parse`d (`providers/npm.ts`,
10
+ * `providers/model.ts`), and parsing amplifies it several-fold in heap. The 256 MiB archive cap only
11
+ * stops the extreme; it still lets a "KB-scale document, GB-scale heap" through.
12
+ *
13
+ * 16 MiB is MEASURED, not guessed. The npm provider asks for `accept: application/json`, i.e. the
14
+ * FULL packument, so the real sizes are the full ones (registry.npmjs.org, 2026-10-02):
15
+ * `lodash` 0.2 MB, `react` 6.7 MB, `aws-sdk` 10.1 MB, `@types/node` 11.2 MB, `typescript` 15.7 MB.
16
+ * 16 MiB keeps every ordinary package installable with headroom while bounding the parse to a
17
+ * four-digit number of MB. The largest packuments on the registry (`npm`, 25.8 MB) now fail
18
+ * terminally as `fetch/too-large` — the intended fail-closed answer for a document that would
19
+ * otherwise be parsed into a multi-GB heap. Anything in that range wants a streamed download with a
20
+ * real cap, not `JSON.parse`. (The abbreviated form — `accept: application/vnd.npm.install-v1+json`
21
+ * — measured 2–10× smaller on the same packages, e.g. `npm` 25.8 MB → 2.5 MB and `typescript`
22
+ * 15.7 MB → 8.7 MB; switching to it would be a separate, observable change to what the provider
23
+ * reads, so it is recorded here as an option rather than done silently.)
24
+ */
25
+ export declare const METADATA_MAX_BYTES: number;
6
26
  /** Default per-request timeout in milliseconds. */
7
27
  export declare const DEFAULT_REQUEST_TIMEOUT_MS = 300000;
8
- /** Combine the caller's `AbortSignal` with a timeout signal. */
28
+ /**
29
+ * Combine the caller's `AbortSignal` with a timeout signal.
30
+ *
31
+ * `timeoutMs` (or, when omitted, the item's `policy.timeoutMs`) is the item's per-request budget;
32
+ * an undeclared value keeps the 300 s default. `0` means NO timeout — the escape hatch a 2 GiB model
33
+ * needs on a link slower than ~7 MB/s, which the fixed default could never finish. `ctx.signal`
34
+ * (shutdown/caller abort) still applies in every case.
35
+ */
9
36
  export declare function signalFor(ctx: ProviderContext, timeoutMs?: number): AbortSignal;
10
- /** Read a response body with a hard byte cap; throws `fetch/failed` when the cap is crossed. */
37
+ /**
38
+ * Bodies at or below this size are assembled in memory exactly as before; a larger body is handed to
39
+ * a caller-supplied {@link BodySpill} instead, so the peak cost of a download stops tracking the file
40
+ * size. The threshold is the ONLY switch, which keeps the common path (a packument, a tarball, a small
41
+ * model file) on the untouched, simplest code while a multi-GB model — the case measured at roughly 2×
42
+ * its size in RSS — streams one chunk at a time.
43
+ *
44
+ * 64 MiB is a deliberate middle: every ordinary body stays in memory, and when one does not, the
45
+ * in-memory cost is already bounded by `2 × spillAtBytes` (the chunks plus the joined copy) regardless
46
+ * of how large the body turns out to be.
47
+ */
48
+ export declare const SPILL_THRESHOLD_BYTES: number;
49
+ /**
50
+ * Where a body that outgrew the in-memory budget goes instead of a `Uint8Array`.
51
+ *
52
+ * `readCappedOrSpill` writes each chunk in arrival order (the prefix that was already buffered is
53
+ * replayed first) and then calls EXACTLY ONE of `finish` (the body ended and its length checked out) or
54
+ * `abort` (any failure, including the cap being crossed or a declared/actual mismatch). A sink must
55
+ * therefore never assume `finish` follows `write`: the reader can reject mid-body.
56
+ */
57
+ export interface BodySpill<T> {
58
+ /** Append one chunk; awaiting it may apply backpressure. */
59
+ write(chunk: Uint8Array): Promise<void>;
60
+ /** The body is complete and length-checked; flush it and produce the caller's result. */
61
+ finish(total: number): Promise<T>;
62
+ /** Discard everything written so far. Must be safe to call after `finish` failed, and must not throw. */
63
+ abort(): Promise<void>;
64
+ }
65
+ /** Options of {@link readCappedOrSpill}. */
66
+ export interface CappedReadOptions<T> {
67
+ /** Hard cap; defaults to {@link DEFAULT_MAX_BYTES}. */
68
+ readonly maxBytes?: number;
69
+ /** Defaults to {@link SPILL_THRESHOLD_BYTES}. */
70
+ readonly spillAtBytes?: number;
71
+ /** Absent ⇒ the whole body is assembled in memory, whatever its size (the historical path). */
72
+ readonly spill?: () => Promise<BodySpill<T>>;
73
+ }
74
+ /**
75
+ * Read a response body with a hard byte cap, optionally spilling a large body to a caller's sink.
76
+ *
77
+ * Two failures that used to share `fetch/failed` are deliberately DIFFERENT codes, because a caller
78
+ * has to treat them differently:
79
+ *
80
+ * - the cap is crossed (declared `content-length`, or bytes actually read) → `fetch/too-large`, a
81
+ * TERMINAL failure: another mirror serves the same oversized object, so falling through cannot
82
+ * help;
83
+ * - the declared length does not match what was read → `fetch/failed`, a TRANSPORT failure: that is
84
+ * a truncated download, exactly the shape that should try the next candidate source.
85
+ *
86
+ * Without a `spill` this is exactly the historical `readCapped`: same codes, same messages, same
87
+ * single up-front `Uint8Array`. With one, the body crosses `spillAtBytes` and from then on every chunk
88
+ * goes straight to the sink — the buffered prefix is replayed once and dropped — so peak memory is one
89
+ * chunk, not the whole file. `docs/DESIGN.md` §6 states the same invariant; `providers/model.ts` and
90
+ * `downloadBytes` below are the two places that branch on it.
91
+ */
92
+ export declare function readCappedOrSpill(response: Response, options?: {
93
+ readonly maxBytes?: number;
94
+ }): Promise<Uint8Array>;
95
+ export declare function readCappedOrSpill<T>(response: Response, options: CappedReadOptions<T> & {
96
+ readonly spill: () => Promise<BodySpill<T>>;
97
+ }): Promise<Uint8Array | T>;
98
+ /**
99
+ * {@link readCappedOrSpill} without a sink: the entire body is assembled in memory.
100
+ *
101
+ * This is the frozen historical behavior — used by everything that NEEDS the bytes in hand (a
102
+ * `JSON.parse`, an archive digest, an in-memory extract). A caller that lands a file on disk should
103
+ * pass a `spill` so a large body never becomes two copies of itself in the heap.
104
+ */
11
105
  export declare function readCapped(response: Response, maxBytes?: number): Promise<Uint8Array>;
12
106
  /** `{url}` templates → concrete URLs, official source first. */
13
107
  export declare function candidateUrls(url: string, mirrors: readonly string[] | undefined): readonly string[];
14
- /** Download the first candidate that answers 2xx; throws `fetch/failed` when none does. */
108
+ /**
109
+ * Download the first candidate that answers 2xx; throws `fetch/failed` when none does (and
110
+ * `fetch/too-large` immediately when a body crosses the hard cap — see {@link readCapped}).
111
+ *
112
+ * DOWNLOADING IS THE SLOWEST THING THIS FRAMEWORK DOES and it used to be completely silent: a 223 MB
113
+ * archive on a mirror that eventually times out looked like eight minutes of nothing, and the first
114
+ * evidence of trouble was the final `fetch/failed` after every candidate had been tried (measured on
115
+ * DSH Desktop — GitHub plus three mirrors, all aborted). One line per attempt, one when a candidate
116
+ * is passed over with its reason, and one on success makes the wait legible while it is happening.
117
+ */
15
118
  export declare function downloadBytes(ctx: ProviderContext, urls: readonly string[], key: string, onProgress?: (event: ProgressEvent) => void): Promise<{
16
119
  readonly bytes: Uint8Array;
17
120
  readonly url: string;
package/dist/net.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"net.d.ts","sourceRoot":"","sources":["../src/net.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEhE,oFAAoF;AACpF,wBAAgB,WAAW,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,KAAK,CAI9D;AAED,qDAAqD;AACrD,eAAO,MAAM,iBAAiB,QAAoB,CAAA;AAElD,mDAAmD;AACnD,eAAO,MAAM,0BAA0B,SAAU,CAAA;AAEjD,gEAAgE;AAChE,wBAAgB,SAAS,CAAC,GAAG,EAAE,eAAe,EAAE,SAAS,GAAE,MAAmC,GAAG,WAAW,CAG3G;AAED,gGAAgG;AAChG,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,QAAQ,EAClB,QAAQ,GAAE,MAA0B,GACnC,OAAO,CAAC,UAAU,CAAC,CAwCrB;AAED,gEAAgE;AAChE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GAAG,SAAS,MAAM,EAAE,CAOpG;AAED,2FAA2F;AAC3F,wBAAsB,aAAa,CACjC,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAC1C,OAAO,CAAC;IAAE,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAqB/D;AAED,yCAAyC;AACzC,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAKtE;AAED,yFAAyF;AACzF,wBAAgB,WAAW,IAAI,MAAM,CAEpC"}
1
+ {"version":3,"file":"net.d.ts","sourceRoot":"","sources":["../src/net.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEhE,oFAAoF;AACpF,wBAAgB,WAAW,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,KAAK,CAI9D;AAED,qDAAqD;AACrD,eAAO,MAAM,iBAAiB,QAAoB,CAAA;AAElD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB,QAAmB,CAAA;AAElD,mDAAmD;AACnD,eAAO,MAAM,0BAA0B,SAAU,CAAA;AAEjD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,eAAe,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,WAAW,CAQ/E;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAAmB,CAAA;AAErD;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC;IAC1B,4DAA4D;IAC5D,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvC,yFAAyF;IACzF,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IACjC,yGAAyG;IACzG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB,CAAC,CAAC;IAClC,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,iDAAiD;IACjD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,+FAA+F;IAC/F,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;CAC7C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;AACpH,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;CAAE,GAC9E,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC,CAAA;AA+D1B;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,QAAQ,EAClB,QAAQ,GAAE,MAA0B,GACnC,OAAO,CAAC,UAAU,CAAC,CAErB;AAED,gEAAgE;AAChE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GAAG,SAAS,MAAM,EAAE,CAOpG;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAC1C,OAAO,CAAC;IAAE,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAiC/D;AAED,yCAAyC;AACzC,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAKtE;AAED,yFAAyF;AACzF,wBAAgB,WAAW,IAAI,MAAM,CAEpC"}
package/dist/net.js CHANGED
@@ -13,24 +13,69 @@ export function fetchImplOf(ctx) {
13
13
  }
14
14
  /** Default hard byte cap for one downloaded body. */
15
15
  export const DEFAULT_MAX_BYTES = 256 * 1024 * 1024;
16
+ /**
17
+ * Hard byte cap for a METADATA body: an npm packument, a model revision response, a siblings list.
18
+ *
19
+ * Metadata is a small JSON document that is then `JSON.parse`d (`providers/npm.ts`,
20
+ * `providers/model.ts`), and parsing amplifies it several-fold in heap. The 256 MiB archive cap only
21
+ * stops the extreme; it still lets a "KB-scale document, GB-scale heap" through.
22
+ *
23
+ * 16 MiB is MEASURED, not guessed. The npm provider asks for `accept: application/json`, i.e. the
24
+ * FULL packument, so the real sizes are the full ones (registry.npmjs.org, 2026-10-02):
25
+ * `lodash` 0.2 MB, `react` 6.7 MB, `aws-sdk` 10.1 MB, `@types/node` 11.2 MB, `typescript` 15.7 MB.
26
+ * 16 MiB keeps every ordinary package installable with headroom while bounding the parse to a
27
+ * four-digit number of MB. The largest packuments on the registry (`npm`, 25.8 MB) now fail
28
+ * terminally as `fetch/too-large` — the intended fail-closed answer for a document that would
29
+ * otherwise be parsed into a multi-GB heap. Anything in that range wants a streamed download with a
30
+ * real cap, not `JSON.parse`. (The abbreviated form — `accept: application/vnd.npm.install-v1+json`
31
+ * — measured 2–10× smaller on the same packages, e.g. `npm` 25.8 MB → 2.5 MB and `typescript`
32
+ * 15.7 MB → 8.7 MB; switching to it would be a separate, observable change to what the provider
33
+ * reads, so it is recorded here as an option rather than done silently.)
34
+ */
35
+ export const METADATA_MAX_BYTES = 16 * 1024 * 1024;
16
36
  /** Default per-request timeout in milliseconds. */
17
37
  export const DEFAULT_REQUEST_TIMEOUT_MS = 300_000;
18
- /** Combine the caller's `AbortSignal` with a timeout signal. */
19
- export function signalFor(ctx, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
20
- const timeout = AbortSignal.timeout(timeoutMs);
38
+ /**
39
+ * Combine the caller's `AbortSignal` with a timeout signal.
40
+ *
41
+ * `timeoutMs` (or, when omitted, the item's `policy.timeoutMs`) is the item's per-request budget;
42
+ * an undeclared value keeps the 300 s default. `0` means NO timeout — the escape hatch a 2 GiB model
43
+ * needs on a link slower than ~7 MB/s, which the fixed default could never finish. `ctx.signal`
44
+ * (shutdown/caller abort) still applies in every case.
45
+ */
46
+ export function signalFor(ctx, timeoutMs) {
47
+ const configured = timeoutMs ?? ctx.policy.timeoutMs;
48
+ if (configured === 0) {
49
+ return ctx.signal ?? new AbortController().signal;
50
+ }
51
+ const effective = configured === undefined || !Number.isFinite(configured) || configured < 0 ? DEFAULT_REQUEST_TIMEOUT_MS : configured;
52
+ const timeout = AbortSignal.timeout(effective);
21
53
  return ctx.signal === undefined ? timeout : AbortSignal.any([ctx.signal, timeout]);
22
54
  }
23
- /** Read a response body with a hard byte cap; throws `fetch/failed` when the cap is crossed. */
24
- export async function readCapped(response, maxBytes = DEFAULT_MAX_BYTES) {
55
+ /**
56
+ * Bodies at or below this size are assembled in memory exactly as before; a larger body is handed to
57
+ * a caller-supplied {@link BodySpill} instead, so the peak cost of a download stops tracking the file
58
+ * size. The threshold is the ONLY switch, which keeps the common path (a packument, a tarball, a small
59
+ * model file) on the untouched, simplest code while a multi-GB model — the case measured at roughly 2×
60
+ * its size in RSS — streams one chunk at a time.
61
+ *
62
+ * 64 MiB is a deliberate middle: every ordinary body stays in memory, and when one does not, the
63
+ * in-memory cost is already bounded by `2 × spillAtBytes` (the chunks plus the joined copy) regardless
64
+ * of how large the body turns out to be.
65
+ */
66
+ export const SPILL_THRESHOLD_BYTES = 64 * 1024 * 1024;
67
+ export async function readCappedOrSpill(response, options = {}) {
68
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
69
+ const spillAtBytes = options.spillAtBytes ?? SPILL_THRESHOLD_BYTES;
25
70
  const declared = Number(response.headers.get('content-length') ?? Number.NaN);
26
71
  if (Number.isFinite(declared) && declared > maxBytes) {
27
- throw new ProvisionError('fetch/failed', `响应声明 ${String(declared)} 字节,超过上限 ${String(maxBytes)}`);
72
+ throw new ProvisionError('fetch/too-large', `响应声明 ${String(declared)} 字节,超过上限 ${String(maxBytes)}`);
28
73
  }
29
74
  const body = response.body;
30
75
  if (body === null) {
31
76
  const bytes = new Uint8Array(await response.arrayBuffer());
32
77
  if (bytes.byteLength > maxBytes) {
33
- throw new ProvisionError('fetch/failed', `响应 ${String(bytes.byteLength)} 字节,超过上限 ${String(maxBytes)}`);
78
+ throw new ProvisionError('fetch/too-large', `响应 ${String(bytes.byteLength)} 字节,超过上限 ${String(maxBytes)}`);
34
79
  }
35
80
  if (Number.isFinite(declared) && declared !== bytes.byteLength) {
36
81
  throw new ProvisionError('fetch/failed', `响应声明 ${String(declared)} 字节,实际收到 ${String(bytes.byteLength)} 字节`);
@@ -40,29 +85,60 @@ export async function readCapped(response, maxBytes = DEFAULT_MAX_BYTES) {
40
85
  const reader = body.getReader();
41
86
  const chunks = [];
42
87
  let total = 0;
43
- for (;;) {
44
- const { done, value } = await reader.read();
45
- if (done)
46
- break;
47
- if (value === undefined)
48
- continue;
49
- total += value.byteLength;
50
- if (total > maxBytes) {
51
- await reader.cancel().catch(() => undefined);
52
- throw new ProvisionError('fetch/failed', `响应超过上限 ${String(maxBytes)} 字节(已读 ${String(total)})`);
88
+ let spill;
89
+ try {
90
+ for (;;) {
91
+ const { done, value } = await reader.read();
92
+ if (done)
93
+ break;
94
+ if (value === undefined)
95
+ continue;
96
+ total += value.byteLength;
97
+ if (total > maxBytes) {
98
+ throw new ProvisionError('fetch/too-large', `响应超过上限 ${String(maxBytes)} 字节(已读 ${String(total)})`);
99
+ }
100
+ // The body has outgrown the in-memory budget: hand the buffered prefix to the sink and never
101
+ // assemble the whole thing again.
102
+ if (spill === undefined && options.spill !== undefined && total > spillAtBytes) {
103
+ spill = await options.spill();
104
+ for (const buffered of chunks)
105
+ await spill.write(buffered);
106
+ chunks.length = 0;
107
+ }
108
+ if (spill === undefined)
109
+ chunks.push(value);
110
+ else
111
+ await spill.write(value);
53
112
  }
54
- chunks.push(value);
55
- }
56
- if (Number.isFinite(declared) && declared !== total) {
57
- throw new ProvisionError('fetch/failed', `响应声明 ${String(declared)} 字节,实际收到 ${String(total)} 字节`);
113
+ if (Number.isFinite(declared) && declared !== total) {
114
+ throw new ProvisionError('fetch/failed', `响应声明 ${String(declared)} 字节,实际收到 ${String(total)} 字节`);
115
+ }
116
+ if (spill !== undefined)
117
+ return await spill.finish(total);
118
+ const bytes = new Uint8Array(total);
119
+ let offset = 0;
120
+ for (const chunk of chunks) {
121
+ bytes.set(chunk, offset);
122
+ offset += chunk.byteLength;
123
+ }
124
+ return bytes;
58
125
  }
59
- const bytes = new Uint8Array(total);
60
- let offset = 0;
61
- for (const chunk of chunks) {
62
- bytes.set(chunk, offset);
63
- offset += chunk.byteLength;
126
+ catch (error) {
127
+ await reader.cancel().catch(() => undefined);
128
+ if (spill !== undefined)
129
+ await spill.abort().catch(() => undefined);
130
+ throw error;
64
131
  }
65
- return bytes;
132
+ }
133
+ /**
134
+ * {@link readCappedOrSpill} without a sink: the entire body is assembled in memory.
135
+ *
136
+ * This is the frozen historical behavior — used by everything that NEEDS the bytes in hand (a
137
+ * `JSON.parse`, an archive digest, an in-memory extract). A caller that lands a file on disk should
138
+ * pass a `spill` so a large body never becomes two copies of itself in the heap.
139
+ */
140
+ export async function readCapped(response, maxBytes = DEFAULT_MAX_BYTES) {
141
+ return readCappedOrSpill(response, { maxBytes });
66
142
  }
67
143
  /** `{url}` templates → concrete URLs, official source first. */
68
144
  export function candidateUrls(url, mirrors) {
@@ -74,14 +150,28 @@ export function candidateUrls(url, mirrors) {
74
150
  }
75
151
  return candidates;
76
152
  }
77
- /** Download the first candidate that answers 2xx; throws `fetch/failed` when none does. */
153
+ /**
154
+ * Download the first candidate that answers 2xx; throws `fetch/failed` when none does (and
155
+ * `fetch/too-large` immediately when a body crosses the hard cap — see {@link readCapped}).
156
+ *
157
+ * DOWNLOADING IS THE SLOWEST THING THIS FRAMEWORK DOES and it used to be completely silent: a 223 MB
158
+ * archive on a mirror that eventually times out looked like eight minutes of nothing, and the first
159
+ * evidence of trouble was the final `fetch/failed` after every candidate had been tried (measured on
160
+ * DSH Desktop — GitHub plus three mirrors, all aborted). One line per attempt, one when a candidate
161
+ * is passed over with its reason, and one on success makes the wait legible while it is happening.
162
+ */
78
163
  export async function downloadBytes(ctx, urls, key, onProgress) {
79
164
  const problems = [];
80
- for (const url of urls) {
165
+ for (const [index, url] of urls.entries()) {
166
+ const position = `${String(index + 1)}/${String(urls.length)}`;
167
+ // The last candidate gets no "trying the next one" promise: there is none.
168
+ const next = index + 1 < urls.length ? ',改用下一个候选源' : ',没有更多候选源';
169
+ ctx.logger.info(`${key}:尝试候选源 ${position} ${url}`);
81
170
  try {
82
171
  const response = await fetchImplOf(ctx)(url, { signal: signalFor(ctx) });
83
172
  if (!response.ok) {
84
173
  problems.push(`${url} → HTTP ${String(response.status)}`);
174
+ ctx.logger.warn(`${key}:${url} 返回 HTTP ${String(response.status)}${next}`);
85
175
  continue;
86
176
  }
87
177
  // A short body is a TRANSPORT failure and retries the next mirror, like a bad status or a
@@ -90,10 +180,18 @@ export async function downloadBytes(ctx, urls, key, onProgress) {
90
180
  // distinction IS the invariant; `docs/DESIGN.md` §6 states it in the same words.
91
181
  const bytes = await readCapped(response);
92
182
  onProgress?.({ key, phase: 'download', loaded: bytes.byteLength, total: bytes.byteLength });
183
+ ctx.logger.info(`${key}:下载完成 ${String(bytes.byteLength)} 字节(${url})`);
93
184
  return { bytes, url };
94
185
  }
95
186
  catch (error) {
96
- problems.push(`${url} → ${error instanceof Error ? error.message : String(error)}`);
187
+ // The byte cap is the one fetch failure that is TERMINAL: every mirror serves the same
188
+ // oversized object, so retrying just burns the remaining candidates. `readCapped` separates it
189
+ // from a truncated body (`fetch/failed`) for exactly this branch.
190
+ if (error instanceof ProvisionError && error.code === 'fetch/too-large')
191
+ throw error;
192
+ const reason = error instanceof Error ? error.message : String(error);
193
+ problems.push(`${url} → ${reason}`);
194
+ ctx.logger.warn(`${key}:${url} 失败(${reason})${next}`);
97
195
  }
98
196
  }
99
197
  throw new ProvisionError('fetch/failed', `所有候选源都失败:${problems.join('; ')}`);
@@ -1 +1 @@
1
- {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../../src/providers/archive.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAA+B,QAAQ,EAAuE,MAAM,aAAa,CAAA;AAG7I,eAAO,MAAM,mBAAmB,mBAAmB,CAAA;AAOnD,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,wBAAwB;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAA;IACnC,kEAAkE;IAClE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,iDAAiD;IACjD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,oCAAoC;IACpC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAA;IACrD,6CAA6C;IAC7C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,iFAAiF;IACjF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,wEAAwE;IACxE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACzC;AA2FD,kDAAkD;AAClD,wBAAgB,qBAAqB,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,QAAQ,CAqJtF"}
1
+ {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../../src/providers/archive.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAA+B,QAAQ,EAAuE,MAAM,aAAa,CAAA;AAG7I,eAAO,MAAM,mBAAmB,mBAAmB,CAAA;AAOnD,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,wBAAwB;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAA;IACnC,kEAAkE;IAClE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,iDAAiD;IACjD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,oCAAoC;IACpC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAA;IACrD,6CAA6C;IAC7C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,iFAAiF;IACjF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,wEAAwE;IACxE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACzC;AAmJD,kDAAkD;AAClD,wBAAgB,qBAAqB,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,QAAQ,CAyLtF"}
@@ -2,11 +2,12 @@
2
2
  * The built-in `binary-archive` provider.
3
3
  * @module providers/archive
4
4
  */
5
- import { delimiter, dirname, join } from 'node:path';
5
+ import { basename, delimiter, dirname, join } from 'node:path';
6
6
  import { execFile } from 'node:child_process';
7
7
  import { ProvisionError } from '../errors.js';
8
8
  import { exists } from '../fs.js';
9
9
  import { sriOfSha256 } from '../integrity.js';
10
+ import { assertSafeRelativePath } from '../layout.js';
10
11
  import { decodeJson, readInstallManifest } from '../manifest.js';
11
12
  import { candidateUrls, downloadBytes, platformKey, verifySha256 } from '../net.js';
12
13
  import { extractTarGz } from '../tar.js';
@@ -88,16 +89,65 @@ function runVersionProbe(path, args, options = {}) {
88
89
  execFile(path, [...args], {
89
90
  timeout: options.timeoutMs ?? VERIFY_TIMEOUT_MS,
90
91
  ...(options.signal === undefined ? {} : { signal: options.signal }),
91
- }, error => {
92
+ }, (error, stdout) => {
93
+ const text = typeof stdout === 'string' ? stdout : '';
92
94
  if (error === null) {
93
- resolve('ok');
95
+ resolve({ outcome: 'ok', stdout: text });
94
96
  return;
95
97
  }
96
98
  const killed = error.killed === true || error.code === 'ETIMEDOUT';
97
- resolve(killed ? 'timeout' : 'failed');
99
+ resolve({ outcome: killed ? 'timeout' : 'failed', stdout: text });
98
100
  });
99
101
  });
100
102
  }
103
+ /**
104
+ * Version-shaped tokens in a probe's output (a leading `v` is part of the decoration, not the number).
105
+ *
106
+ * A version is confirmed by an EQUAL token, never by the exit code: any same-named binary on PATH
107
+ * answers `--version` with 0. An exact-token rule can only produce a false NEGATIVE (a tool that
108
+ * prints more precision than the spec pins, e.g. `1.0.0.1` for `1.0.0`), which degrades to "present,
109
+ * version unknown" — the same safe shape as a timeout.
110
+ */
111
+ const VERSION_TOKEN = /(?:^|[^0-9A-Za-z])v?(\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/g;
112
+ /** The bare numeric core of a version: `4.4.2-0ubuntu0.22.04.1` → `4.4.2`. */
113
+ const VERSION_CORE = /^\d+(?:\.\d+)*/;
114
+ /** A pin that is itself nothing but the numeric core (no distro/prerelease/build decoration). */
115
+ const PLAIN_NUMERIC = /^\d+(?:\.\d+)*$/;
116
+ /**
117
+ * Does the probe output report `expected`?
118
+ *
119
+ * The observed token may carry a DISTRO/BUILD decoration that is not part of the version identity —
120
+ * measured: `ffmpeg version 4.4.2-0ubuntu0.22.04.1` against a pin of `4.4.2`. That produced a MISS,
121
+ * which is fail-closed (the caller re-downloads a tool it already has) but wrong: the decorated
122
+ * token IS the pinned version. So a PLAIN NUMERIC pin is compared against each token's numeric core;
123
+ * a pin that is not plain numeric (a prerelease/build pin such as `1.2.3-rc.1`) keeps the exact-token
124
+ * rule, because there the suffix is exactly what is being pinned. A genuinely different version still
125
+ * misses: the core is compared whole (`4.5.0` and `1.0.0.1` are not `4.4.2`/`1.0.0`), never as a
126
+ * string prefix, so `4.4` cannot match `4.4.2` and `1.0.0` cannot match `1.0.0.1`.
127
+ */
128
+ function reportsVersion(stdout, expected) {
129
+ const wanted = expected.replace(/^v/, '');
130
+ const compareCore = PLAIN_NUMERIC.test(wanted);
131
+ for (const match of stdout.matchAll(VERSION_TOKEN)) {
132
+ const token = match[1];
133
+ if (token === undefined)
134
+ continue;
135
+ if (token === wanted)
136
+ return true;
137
+ if (!compareCore)
138
+ continue;
139
+ // `VERSION_CORE` is anchored, so this is the token's numeric head, not a substring search.
140
+ const core = VERSION_CORE.exec(token)?.[0];
141
+ if (core === wanted)
142
+ return true;
143
+ }
144
+ return false;
145
+ }
146
+ /** One line of probe output, for a warning that stays readable. */
147
+ function firstLine(stdout) {
148
+ const line = stdout.split('\n').find(candidate => candidate.trim() !== '');
149
+ return (line ?? '').trim().slice(0, 120);
150
+ }
101
151
  /** Build the built-in binary-archive provider. */
102
152
  export function binaryArchiveProvider(options = {}) {
103
153
  const id = options.id ?? '@avantf/dsh-plugin-base/binary-archive';
@@ -123,14 +173,24 @@ export function binaryArchiveProvider(options = {}) {
123
173
  const spec = parseSpec(item);
124
174
  const binary = binaryOf(spec);
125
175
  const args = spec.versionArgs ?? ['--version'];
126
- // A PATH/explicit hit is confirmed with the same `--version` probe as verify.
176
+ // A PATH/explicit hit is confirmed by the `--version` probe's OUTPUT, not its exit code: any
177
+ // same-named binary exits 0, and trusting that made an arbitrary `pandoc` on PATH masquerade as
178
+ // the pinned version (its directory then went into PATH and status.json). A mismatch is a miss.
127
179
  const check = async (path) => {
128
- if (args.length === 0)
129
- return { version: spec.version };
130
- const outcome = await runVersionProbe(path, args, { timeoutMs: PROBE_TIMEOUT_MS, ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
131
- if (outcome === 'ok')
132
- return { version: spec.version };
133
- if (outcome === 'timeout') {
180
+ // No probe command ⇒ the binary may exist, but there is no way to know its version. Say so
181
+ // (the same shape as a timeout) instead of copying the assertion in the spec.
182
+ if (args.length === 0) {
183
+ ctx.logger.warn(`${path} 未配置版本探针(versionArgs 为空);按"存在但版本未知"处理`);
184
+ return {};
185
+ }
186
+ const probed = await runVersionProbe(path, args, { timeoutMs: PROBE_TIMEOUT_MS, ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
187
+ if (probed.outcome === 'ok') {
188
+ if (reportsVersion(probed.stdout, spec.version))
189
+ return { version: spec.version };
190
+ ctx.logger.warn(`${path} 的 ${args.join(' ')} 未报告 ${spec.version}(输出:${firstLine(probed.stdout)});按未命中处理`);
191
+ return undefined;
192
+ }
193
+ if (probed.outcome === 'timeout') {
134
194
  ctx.logger.warn(`${path} 的 ${args.join(' ')} 探针超时;按"存在但版本未知"处理`);
135
195
  return {};
136
196
  }
@@ -153,12 +213,21 @@ export function binaryArchiveProvider(options = {}) {
153
213
  const manifest = readInstallManifest(decodeJson(await ctx.fs.readFile(manifestPath)), BINARY_ARCHIVE_KIND);
154
214
  if (manifest === undefined || manifest.version !== spec.version)
155
215
  continue;
156
- // The manifest's entry is authoritative.
216
+ // The manifest's entry is authoritative — and the read-back side still validates it, so a
217
+ // hand-edited/corrupt install.json cannot point the join outside the version directory.
157
218
  const relative = manifest.entry ?? `bin/${binaryOf(spec, spec.packs[platformKey()])}`;
158
- const executable = join(root, entry, ...relative.split('/'));
219
+ let executable;
220
+ try {
221
+ assertSafeRelativePath(relative);
222
+ executable = join(root, entry, ...relative.split('/'));
223
+ }
224
+ catch {
225
+ ctx.logger.warn(`install.json 的 entry 不安全,已跳过:${relative}(${join(root, entry)})`);
226
+ continue;
227
+ }
159
228
  if (await exists(ctx.fs, executable)) {
160
229
  const dir = dirname(executable);
161
- return { found: true, version: spec.version, dir, source: 'managed', env: pathEnvOf(dir) };
230
+ return { found: true, version: manifest.version, dir, source: 'managed', env: pathEnvOf(dir) };
162
231
  }
163
232
  }
164
233
  }
@@ -205,7 +274,14 @@ export function binaryArchiveProvider(options = {}) {
205
274
  }
206
275
  const binDir = join(staging, 'bin');
207
276
  await ctx.fs.mkdir(binDir);
208
- const target = join(binDir, binary);
277
+ // The name the archive ACTUALLY carries, not the declared one. A pack names its executable
278
+ // (`pandoc`) while a Windows archive ships `pandoc.exe`, and Windows cannot execute a PE image
279
+ // under a name without the extension: installing it as `bin/pandoc` made the mandatory
280
+ // `--version` probe fail and quarantined a freshly downloaded 223 MB tool (measured on DSH
281
+ // Desktop). The manifest records the real name, and `probe`/`verify` read the entry from there,
282
+ // so resolution follows it without a second rule.
283
+ const installed = basename(found);
284
+ const target = join(binDir, installed);
209
285
  await ctx.fs.copyFile(found, target);
210
286
  await ctx.fs.chmod(target, 0o755);
211
287
  await ctx.fs.rm(unpack, { recursive: true });
@@ -215,7 +291,7 @@ export function binaryArchiveProvider(options = {}) {
215
291
  integrity: sriOfSha256(pack.sha256),
216
292
  tarball: pack.url,
217
293
  source: 'installed',
218
- entry: `bin/${binary}`,
294
+ entry: `bin/${installed}`,
219
295
  entryDir: 'bin',
220
296
  };
221
297
  return ctx.publish(staging, meta);
@@ -225,7 +301,19 @@ export function binaryArchiveProvider(options = {}) {
225
301
  // The published entry is the manifest's.
226
302
  const manifest = readInstallManifest(decodeJson(await ctx.fs.readFile(join(resolved.dir, 'install.json'))), BINARY_ARCHIVE_KIND);
227
303
  const entry = manifest?.entry;
228
- const path = entry === undefined ? join(resolved.entryDir, binaryOf(spec)) : join(resolved.dir, ...entry.split('/'));
304
+ let path;
305
+ if (entry === undefined) {
306
+ path = join(resolved.entryDir, binaryOf(spec));
307
+ }
308
+ else {
309
+ try {
310
+ assertSafeRelativePath(entry);
311
+ path = join(resolved.dir, ...entry.split('/'));
312
+ }
313
+ catch {
314
+ throw new ProvisionError('verify/failed', `install.json 的 entry 不安全:${entry}`);
315
+ }
316
+ }
229
317
  if (!(await exists(ctx.fs, path))) {
230
318
  throw new ProvisionError('verify/failed', `归档可执行物不存在:${path}`);
231
319
  }
@@ -233,13 +321,13 @@ export function binaryArchiveProvider(options = {}) {
233
321
  const args = spec.versionArgs ?? ['--version'];
234
322
  if (args.length === 0)
235
323
  return;
236
- const outcome = await runVersionProbe(path, args, { ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
237
- if (outcome === 'timeout') {
324
+ const probed = await runVersionProbe(path, args, { ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
325
+ if (probed.outcome === 'timeout') {
238
326
  // A timeout leaves the binary usable; only a failed probe fails verification.
239
327
  ctx.logger.warn(`${path} 的 ${args.join(' ')} 探针超时;按可用处理`);
240
328
  return;
241
329
  }
242
- if (outcome === 'failed') {
330
+ if (probed.outcome === 'failed') {
243
331
  throw new ProvisionError('verify/failed', `${path} 的 ${args.join(' ')} 探针失败`);
244
332
  }
245
333
  },
@@ -1,5 +1,21 @@
1
1
  import type { Provider } from '../types.js';
2
2
  export declare const MODEL_CACHE_KIND = "model-cache";
3
+ /**
4
+ * Maximum bytes read for a single model file.
5
+ *
6
+ * 2 GiB, not the old 4 GiB. The cap is the number of bytes the MEMORY path would have to hold TWICE
7
+ * (the received chunks plus the joined copy), so it bounds the worst-case spike directly; the measured
8
+ * ~2× peak for a body of this size is ~4 GiB, which a 16 GB machine survives and a 4 GiB cap does not.
9
+ * 2 GiB is also still far above what the framework is for: a real `bge-small` is ~90 MB and even a
10
+ * multi-billion-parameter checkpoint is a handful of files well under this. Anything larger wants a
11
+ * different transport, not a bigger heap.
12
+ *
13
+ * (The review's "4 GiB is exactly Node's `kMaxLength`" does NOT reproduce on the target runtime:
14
+ * Node 22.23.2 / linux-x64 reports `buffer.constants.MAX_LENGTH` 2^53−1 and allocates a 4 GiB
15
+ * `Uint8Array` fine. The rejection boundary is a legitimate design choice here, not a crash we are
16
+ * ducking — see the report accompanying this change.)
17
+ */
18
+ export declare const MAX_MODEL_BYTES: number;
3
19
  /** How an item's files are placed under `target.root`.
4
20
  * @stable */
5
21
  export type ModelCacheLayout = 'hub' | 'flat';
@@ -18,8 +34,15 @@ export interface ModelCacheSpec {
18
34
  /** Extra files that must exist for `verify` to pass. */
19
35
  readonly requiredFiles?: readonly string[];
20
36
  }
21
- /** Build the built-in model-cache provider. */
22
- export declare function modelCacheProvider(options?: {
37
+ /** The options of {@link modelCacheProvider}; the byte limits are injectable so tests can exercise the
38
+ * spill boundary without multi-hundred-MB bodies. `@experimental` */
39
+ export interface ModelCacheProviderOptions {
23
40
  readonly id?: string;
24
- }): Provider;
41
+ /** Hard per-file byte cap; default {@link MAX_MODEL_BYTES} (2 GiB). */
42
+ readonly maxBytes?: number;
43
+ /** Body size above which a file streams to disk; default {@link SPILL_THRESHOLD_BYTES} (64 MiB). */
44
+ readonly spillAtBytes?: number;
45
+ }
46
+ /** Build the built-in model-cache provider. */
47
+ export declare function modelCacheProvider(options?: ModelCacheProviderOptions): Provider;
25
48
  //# sourceMappingURL=model.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../../src/providers/model.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAGV,QAAQ,EAOT,MAAM,aAAa,CAAA;AAEpB,eAAO,MAAM,gBAAgB,gBAAgB,CAAA;AAoB7C;cACc;AACd,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,MAAM,CAAA;AAY7C,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,sCAAsC;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,qCAAqC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,gBAAgB,CAAA;IAClC,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,wDAAwD;IACxD,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC3C;AA8ZD,+CAA+C;AAC/C,wBAAgB,kBAAkB,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,QAAQ,CAgHnF"}
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../../src/providers/model.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAGV,QAAQ,EAOT,MAAM,aAAa,CAAA;AAEpB,eAAO,MAAM,gBAAgB,gBAAgB,CAAA;AAG7C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,QAAyB,CAAA;AAuBrD;cACc;AACd,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,MAAM,CAAA;AAY7C,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,sCAAsC;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,qCAAqC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,gBAAgB,CAAA;IAClC,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,wDAAwD;IACxD,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC3C;AA4gBD;sEACsE;AACtE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;IACpB,uEAAuE;IACvE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,oGAAoG;IACpG,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,yBAA8B,GAAG,QAAQ,CA8GpF"}