@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.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.
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
  import type { CheckSymbolExistsOutput, FindMappingOutput, GetClassApiMatrixOutput, ResolveMethodMappingExactOutput, ResolveWorkspaceSymbolOutput, TraceSymbolLifecycleOutput } from "../source-service.js";
3
3
  export declare const analyzeSymbolShape: {
4
4
  task: z.ZodEnum<{
5
- map: "map";
6
5
  workspace: "workspace";
6
+ map: "map";
7
7
  exists: "exists";
8
8
  "exact-map": "exact-map";
9
9
  lifecycle: "lifecycle";
@@ -72,8 +72,8 @@ export declare const analyzeSymbolShape: {
72
72
  };
73
73
  export declare const analyzeSymbolSchema: z.ZodObject<{
74
74
  task: z.ZodEnum<{
75
- map: "map";
76
75
  workspace: "workspace";
76
+ map: "map";
77
77
  exists: "exists";
78
78
  "exact-map": "exact-map";
79
79
  lifecycle: "lifecycle";
@@ -5,10 +5,10 @@ export declare const detailSchema: z.ZodEnum<{
5
5
  full: "full";
6
6
  }>;
7
7
  export declare const includeGroupSchema: z.ZodEnum<{
8
- entries: "entries";
9
8
  workspace: "workspace";
10
- candidates: "candidates";
9
+ entries: "entries";
11
10
  source: "source";
11
+ candidates: "candidates";
12
12
  warnings: "warnings";
13
13
  registry: "registry";
14
14
  preview: "preview";
@@ -78,6 +78,15 @@ export declare class JsonRpcFrameReader {
78
78
  private awaitedBodyStart;
79
79
  private idleTimer;
80
80
  private fatal;
81
+ /**
82
+ * Set when an oversized, not-yet-terminated line/header-less run was just
83
+ * discarded with no newline in hand. The bytes that eventually complete
84
+ * that same logical line are not a delimiter the reader can trust as the
85
+ * start of a fresh frame, so every byte up to and including the next 0x0a —
86
+ * however many chunks it takes to arrive — is swallowed unread before
87
+ * normal parsing resumes. See `rejectOversizedIncompleteInput`.
88
+ */
89
+ private discardingOversizedLine;
81
90
  /**
82
91
  * @param options.maxFrameBytes Largest accepted frame; defaults to
83
92
  * {@link loadMaxFrameBytes}.
@@ -128,6 +137,17 @@ export declare class JsonRpcFrameReader {
128
137
  * a caller that does not still gets a live pair rather than a stale one.
129
138
  */
130
139
  private armIdleTimer;
140
+ /**
141
+ * Whether the buffer, despite `mode` still sticking at "content-length"
142
+ * from an earlier frame, actually opens a line-mode frame — the same probe
143
+ * `readContentLengthMessage` uses to detect the mid-stream switch back to
144
+ * line framing (a JSON object/array opener can never begin a Content-Length
145
+ * header block). Used to keep the header-size ceiling scoped to buffers
146
+ * still being accumulated as a header block, so it never judges a line
147
+ * frame's bytes as an oversized header just because the switch hasn't been
148
+ * recognized yet.
149
+ */
150
+ private looksLikeLineFrame;
131
151
  private canCompleteFrame;
132
152
  private rejectOversizedIncompleteInput;
133
153
  /**
@@ -16,23 +16,72 @@ const clearIdleTimerHandle = (handle) => {
16
16
  clearNodeTimeout(handle);
17
17
  };
18
18
  /**
19
- * The FIRST header terminator in the buffer, whichever style it is.
19
+ * The end of the header block: the FIRST EMPTY LINE in the buffer.
20
20
  *
21
- * Preferring a CRLFCRLF found anywhere over an earlier LFLF mis-framed every
22
- * LF-framed peer whose JSON body happened to contain a raw `\r\n\r\n` legal
23
- * inter-token whitespace because the header block was then cut at a boundary
24
- * inside the body, losing that frame and the next.
21
+ * One rule generates every terminator style instead of a list of literals to
22
+ * match. A header line ends at an LF, and a CR immediately before that LF
23
+ * belongs to the terminator rather than to the line; the block ends at the
24
+ * first line that holds nothing but its own terminator. `index` is the first
25
+ * byte of that terminating sequence — so `buffer.slice(0, index)` is exactly
26
+ * the header text — and `delimiterBytes` spans through the empty line's LF, so
27
+ * the body opens at `index + delimiterBytes`. The four shapes a peer can
28
+ * produce are consequences, not cases:
29
+ *
30
+ * "\r\n\r\n" → 4 bytes "\r\n\n" → 3 bytes
31
+ * "\n\r\n" → 3 bytes "\n\n" → 2 bytes
32
+ *
33
+ * Scanning forward from byte 0 is also what keeps a real terminator ahead of
34
+ * any byte sequence inside a body: preferring a CRLFCRLF found ANYWHERE over
35
+ * an earlier LFLF mis-framed every LF-framed peer whose JSON body happened to
36
+ * contain a raw `\r\n\r\n` — legal inter-token whitespace — because the header
37
+ * block was then cut at a boundary inside the body, losing that frame and the
38
+ * next. A single forward scan cannot reach the body before the block ends, so
39
+ * no body byte can outrank the terminator and no two readings compete.
40
+ *
41
+ * Consequence worth stating, because it is load-bearing for the caller: an
42
+ * EXTRA empty line after the terminator is body, not header. The body window
43
+ * then opens on that empty line, shifted by however many bytes that line's own
44
+ * terminator took — one for a bare LF, two for CRLF — bytes the peer's
45
+ * declared length did not count, so the window no longer covers the same span
46
+ * as the JSON value.
47
+ * All four extra-blank-line shapes behave alike here, which is the point: the
48
+ * reading does not depend on which terminator style the peer chose.
49
+ *
50
+ * What that shift COSTS is a separate question, and the answer is not always
51
+ * "the frame". The usual outcome is a window running off the end of the JSON,
52
+ * `readContentLengthMessage`'s body-parse failure and a framing-fatal — see
53
+ * {@link JsonRpcFramingFatalError}. But the window is only shifted, not
54
+ * mis-sized, so trailing whitespace INSIDE the declared length can absorb the
55
+ * shift exactly: a length that counts two trailing spaces, against two
56
+ * uncounted leading bytes, lands the window on `"\r\n" + <json>`, which
57
+ * `JSON.parse` accepts. Such a frame is delivered normally. The reader does
58
+ * not detect the extra blank line; it only ever sees where the bytes fall.
25
59
  */
26
60
  function findHeaderBoundary(buffer) {
27
- const crlfBoundary = buffer.indexOf("\r\n\r\n");
28
- const lfBoundary = buffer.indexOf("\n\n");
29
- if (crlfBoundary !== -1 && (lfBoundary === -1 || crlfBoundary < lfBoundary)) {
30
- return { index: crlfBoundary, delimiterBytes: 4 };
31
- }
32
- if (lfBoundary !== -1) {
33
- return { index: lfBoundary, delimiterBytes: 2 };
61
+ let searchFrom = 0;
62
+ while (true) {
63
+ const lineEnd = buffer.indexOf(0x0a, searchFrom);
64
+ if (lineEnd === -1) {
65
+ return undefined;
66
+ }
67
+ // The next line starts immediately after that LF. It is the empty line
68
+ // when the only bytes it holds are its own terminator: an optional CR and
69
+ // then an LF. An out-of-range read is `undefined`, which matches neither
70
+ // byte, so a truncated tail simply keeps the scan waiting for more input.
71
+ let cursor = lineEnd + 1;
72
+ if (buffer[cursor] === 0x0d) {
73
+ cursor += 1;
74
+ }
75
+ if (buffer[cursor] === 0x0a) {
76
+ // A CR in front of the FIRST LF terminates the preceding header line, so
77
+ // it is part of the delimiter, not of the headers. Guarding on
78
+ // `lineEnd > 0` keeps the lookbehind inside the buffer when the block is
79
+ // terminated at byte 0.
80
+ const index = lineEnd > 0 && buffer[lineEnd - 1] === 0x0d ? lineEnd - 1 : lineEnd;
81
+ return { index, delimiterBytes: cursor + 1 - index };
82
+ }
83
+ searchFrom = lineEnd + 1;
34
84
  }
35
- return undefined;
36
85
  }
37
86
  function parseJsonRpcMessage(json) {
38
87
  return parseJSONRPCMessage(JSON.parse(json));
@@ -124,6 +173,15 @@ export class JsonRpcFrameReader {
124
173
  awaitedBodyStart = -1;
125
174
  idleTimer;
126
175
  fatal = false;
176
+ /**
177
+ * Set when an oversized, not-yet-terminated line/header-less run was just
178
+ * discarded with no newline in hand. The bytes that eventually complete
179
+ * that same logical line are not a delimiter the reader can trust as the
180
+ * start of a fresh frame, so every byte up to and including the next 0x0a —
181
+ * however many chunks it takes to arrive — is swallowed unread before
182
+ * normal parsing resumes. See `rejectOversizedIncompleteInput`.
183
+ */
184
+ discardingOversizedLine = false;
127
185
  /**
128
186
  * @param options.maxFrameBytes Largest accepted frame; defaults to
129
187
  * {@link loadMaxFrameBytes}.
@@ -159,6 +217,7 @@ export class JsonRpcFrameReader {
159
217
  this.awaitedFrameEnd = -1;
160
218
  this.awaitedBodyStart = -1;
161
219
  this.fatal = false;
220
+ this.discardingOversizedLine = false;
162
221
  }
163
222
  clear() {
164
223
  this.clearIdleTimer();
@@ -169,6 +228,7 @@ export class JsonRpcFrameReader {
169
228
  this.awaitedFrameEnd = -1;
170
229
  this.awaitedBodyStart = -1;
171
230
  this.fatal = false;
231
+ this.discardingOversizedLine = false;
172
232
  }
173
233
  processChunk(chunk, handlers) {
174
234
  if (chunk.length === 0 || this.fatal) {
@@ -195,6 +255,24 @@ export class JsonRpcFrameReader {
195
255
  this.pendingBytes = 0;
196
256
  while (true) {
197
257
  try {
258
+ if (this.discardingOversizedLine) {
259
+ // Swallow bytes up to and including the next newline WITHOUT
260
+ // interpreting them as a frame — they are the tail of the line just
261
+ // rejected as oversized, not a fresh start, even if they happen to
262
+ // look like a well-formed message on their own. Only once that
263
+ // terminator is found does this resynchronize on the byte position
264
+ // the peer itself delimited.
265
+ const newlineIndex = this.buffer.indexOf(0x0a);
266
+ if (newlineIndex === -1) {
267
+ // The discarded bytes carry no information, so there is nothing
268
+ // to hold onto while waiting for the terminator.
269
+ this.buffer = Buffer.alloc(0);
270
+ return;
271
+ }
272
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
273
+ this.discardingOversizedLine = false;
274
+ continue;
275
+ }
198
276
  this.rejectOversizedIncompleteInput();
199
277
  if (this.mode === "unknown") {
200
278
  const detected = this.detectMode();
@@ -250,6 +328,7 @@ export class JsonRpcFrameReader {
250
328
  this.buffer = Buffer.alloc(0);
251
329
  this.pendingChunks = [];
252
330
  this.pendingBytes = 0;
331
+ this.discardingOversizedLine = false;
253
332
  handlers.onError(error);
254
333
  return;
255
334
  }
@@ -314,6 +393,28 @@ export class JsonRpcFrameReader {
314
393
  handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
315
394
  this.idleTimer = handle;
316
395
  }
396
+ /**
397
+ * Whether the buffer, despite `mode` still sticking at "content-length"
398
+ * from an earlier frame, actually opens a line-mode frame — the same probe
399
+ * `readContentLengthMessage` uses to detect the mid-stream switch back to
400
+ * line framing (a JSON object/array opener can never begin a Content-Length
401
+ * header block). Used to keep the header-size ceiling scoped to buffers
402
+ * still being accumulated as a header block, so it never judges a line
403
+ * frame's bytes as an oversized header just because the switch hasn't been
404
+ * recognized yet.
405
+ */
406
+ looksLikeLineFrame() {
407
+ let probeIndex = 0;
408
+ while (probeIndex < this.buffer.length &&
409
+ (this.buffer[probeIndex] === 0x20 ||
410
+ this.buffer[probeIndex] === 0x09 ||
411
+ this.buffer[probeIndex] === 0x0d ||
412
+ this.buffer[probeIndex] === 0x0a)) {
413
+ probeIndex += 1;
414
+ }
415
+ return (probeIndex < this.buffer.length &&
416
+ (this.buffer[probeIndex] === 0x7b /* '{' */ || this.buffer[probeIndex] === 0x5b /* '[' */));
417
+ }
317
418
  canCompleteFrame(chunk) {
318
419
  const bufferedBytes = this.buffer.length + this.pendingBytes;
319
420
  if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
@@ -326,8 +427,17 @@ export class JsonRpcFrameReader {
326
427
  return chunk.includes(0x0a) || bufferedBytes > this.maxFrameBytes;
327
428
  }
328
429
  rejectOversizedIncompleteInput() {
329
- const headerBoundary = this.mode === "content-length" ? findHeaderBoundary(this.buffer) : undefined;
330
- if (this.mode === "content-length" &&
430
+ // Sticky "content-length" mode only means a header block is being
431
+ // accumulated when the buffer doesn't already look like a line frame; a
432
+ // JSON object/array opener here is the same mid-stream switch
433
+ // `readContentLengthMessage` recognizes, just not yet reached. The header
434
+ // ceiling below must be scoped to actual header accumulation, or a large
435
+ // line-mode frame arriving right after a Content-Length frame gets judged
436
+ // as an oversized header before the switch is detected.
437
+ const isLineFrameAfterContentLength = this.mode === "content-length" && this.looksLikeLineFrame();
438
+ const inHeaderAccumulation = this.mode === "content-length" && !isLineFrameAfterContentLength;
439
+ const headerBoundary = inHeaderAccumulation ? findHeaderBoundary(this.buffer) : undefined;
440
+ if (inHeaderAccumulation &&
331
441
  !headerBoundary &&
332
442
  this.buffer.length > MAX_CONTENT_LENGTH_HEADER_BYTES) {
333
443
  // No header terminator anywhere in an over-limit header block: there is
@@ -340,15 +450,25 @@ export class JsonRpcFrameReader {
340
450
  if (this.buffer.length <= this.maxFrameBytes) {
341
451
  return;
342
452
  }
343
- if (this.mode === "content-length" && headerBoundary) {
453
+ if (inHeaderAccumulation && headerBoundary) {
344
454
  return;
345
455
  }
346
- if (this.mode !== "content-length" && this.buffer.includes(0x0a)) {
456
+ if (!inHeaderAccumulation && this.buffer.includes(0x0a)) {
347
457
  return;
348
458
  }
349
459
  const observedBytes = this.buffer.length;
350
- const description = this.mode === "line" ? "Line-delimited JSON-RPC frame" : "Headerless JSON-RPC input";
460
+ const description = this.mode === "line" || isLineFrameAfterContentLength
461
+ ? "Line-delimited JSON-RPC frame"
462
+ : "Headerless JSON-RPC input";
351
463
  this.buffer = Buffer.alloc(0);
464
+ if (!inHeaderAccumulation) {
465
+ // The oversized run has no newline anywhere in it yet (the check above
466
+ // would otherwise have returned): remember to swallow bytes through the
467
+ // eventual terminator — wherever it arrives — before resuming normal
468
+ // parsing, so the discarded line's own tail is never re-read as a fresh
469
+ // frame (see `discardingOversizedLine` in `drainChunk`).
470
+ this.discardingOversizedLine = true;
471
+ }
352
472
  throw new Error(`${description} is ${observedBytes} bytes, exceeding the configured frame limit of ` +
353
473
  `${this.maxFrameBytes} bytes.`);
354
474
  }
@@ -435,11 +555,23 @@ export class JsonRpcFrameReader {
435
555
  continue;
436
556
  }
437
557
  if (/^\s*content-length\s*:/i.test(line)) {
438
- // Always re-inject with CRLF: choosing the separator from the next
439
- // buffered byte raced byte-granular chunking (the peer's \r may not
440
- // have arrived yet, yielding a "\n\r\n" boundary findHeaderBoundary
441
- // cannot see). With CRLF both peer styles stay recognizable:
442
- // "...\r\n" + "\r\n…" "\r\n\r\n", "...\r\n" + "\n…" → "\n\n".
558
+ // `line` has already had its trailing \r stripped, so the terminator
559
+ // has to be written back. Always re-inject CRLF: choosing the
560
+ // separator from the next buffered byte raced byte-granular chunking
561
+ // (the peer's \r may not have arrived yet, so the boundary style would
562
+ // depend on read timing rather than on the peer), and this keeps the
563
+ // output independent of arrival.
564
+ //
565
+ // It does not cost the FRAMING: findHeaderBoundary sees an empty line
566
+ // next either way — "...\r\n" + "\r\n…" and "...\r\n" + "\n…" are both
567
+ // first-empty-line boundaries — so no boundary moves. It does cost one
568
+ // BYTE when the peer wrote a bare LF, and that byte is measured
569
+ // against MAX_CONTENT_LENGTH_HEADER_BYTES: an LF-framed header block of
570
+ // exactly 8192 bytes frames on its own, but re-injected here it
571
+ // measures 8193 and is rejected. Exactly one block size is affected —
572
+ // 8192, since anything larger was already over the ceiling — and only
573
+ // when it follows a line-delimited frame, which is why this is left as
574
+ // it stands rather than traded for the chunking race.
443
575
  this.buffer = Buffer.concat([Buffer.from(`${line}\r\n`, "utf8"), this.buffer]);
444
576
  this.mode = "content-length";
445
577
  return undefined;
@@ -1,2 +1,11 @@
1
+ import type { DirectionIndex, PairKey } from "../internal-types.js";
1
2
  import type { MappingLoaderDeps, MappingLoaderResult } from "./types.js";
3
+ /**
4
+ * `maxEntryBytes` reuses the same ceiling as nested-jar extraction
5
+ * ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
6
+ * download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
7
+ * decompressed in full before parsing, so an entry with a small compressed
8
+ * size and a huge inflated size (zip-bomb style) must still be bounded here.
9
+ */
10
+ export declare function parseTinyFromJar(jarPath: string, maxEntryBytes?: number): Promise<Map<PairKey, DirectionIndex>>;
2
11
  export declare function loadTinyPairsFromMaven(deps: MappingLoaderDeps, version: string): Promise<MappingLoaderResult>;
@@ -1,4 +1,5 @@
1
1
  import { defaultDownloadPath, downloadToCache } from "../../repo-downloader.js";
2
+ import { loadMaxNestedJarEntryBytes } from "../../source/nested-jars.js";
2
3
  import { collectMatchedJarEntriesAsUtf8 } from "../../source-jar-reader.js";
3
4
  import { mergeDirectionIndexes } from "../parsers/symbol-records.js";
4
5
  import { parseTinyMappingsInto } from "../parsers/tiny.js";
@@ -27,8 +28,15 @@ async function fetchYarnCoordinates(fetchFn, repoBase, version) {
27
28
  return [version];
28
29
  }
29
30
  }
30
- async function parseTinyFromJar(jarPath) {
31
- const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true })).sort((left, right) => left.filePath.localeCompare(right.filePath));
31
+ /**
32
+ * `maxEntryBytes` reuses the same ceiling as nested-jar extraction
33
+ * ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
34
+ * download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
35
+ * decompressed in full before parsing, so an entry with a small compressed
36
+ * size and a huge inflated size (zip-bomb style) must still be bounded here.
37
+ */
38
+ export async function parseTinyFromJar(jarPath, maxEntryBytes = loadMaxNestedJarEntryBytes()) {
39
+ const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true, maxBytes: maxEntryBytes })).sort((left, right) => left.filePath.localeCompare(right.filePath));
32
40
  // Parsed straight into the shared accumulator: a parse-then-merge loop would
33
41
  // hold each entry's full index alongside the accumulated one. `ensurePairIndex`
34
42
  // + `addLookupEntries` union into what is already there, matching what
@@ -1,7 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { createError, ERROR_CODES } from "./errors.js";
3
3
  import { loadConfig } from "./config.js";
4
- import { artifactSignatureFromFile, normalizeJarPath } from "./path-resolver.js";
4
+ import { normalizeJarPath, readJarStatStamp } from "./path-resolver.js";
5
5
  import { createJarEntryReader } from "./source-jar-reader.js";
6
6
  import { artifactIdNamesMinecraftRuntime } from "./source/artifact-resolver.js";
7
7
  import { matchesMemberPattern } from "./source/member-pattern.js";
@@ -988,7 +988,7 @@ export class MinecraftExplorerService {
988
988
  // With no derivable version the conservative "obfuscated" stands — which
989
989
  // is also the right default for a dependency jar's own bytecode namespace.
990
990
  mappingNamespace: minecraftVersion && isUnobfuscatedVersion(minecraftVersion) ? "mojang" : "obfuscated",
991
- jarSignature: artifactSignatureFromFile(jarPath).sourceArtifactId,
991
+ jarSignature: readJarStatStamp(jarPath).sourceArtifactId,
992
992
  generatedAt: new Date().toISOString()
993
993
  };
994
994
  }
@@ -1,4 +1,4 @@
1
- import type { ArtifactSignature } from "./types.js";
1
+ import type { JarStatStamp } from "./types.js";
2
2
  export interface ResolvedJarInfo {
3
3
  originalPath: string;
4
4
  resolvedPath: string;
@@ -9,7 +9,18 @@ export declare function buildJarSignature(stats: {
9
9
  mtimeMs: number;
10
10
  size: number;
11
11
  }): string;
12
- export declare function artifactSignatureFromFile(jarPath: string): ArtifactSignature;
12
+ /**
13
+ * Read a jar's stat stamp: it stats the file, it does not read a byte of it.
14
+ *
15
+ * The name says stat because a stat is all this is. Artifact identity is
16
+ * derived and composed in `src/artifact-identity.ts`; nothing this returns is
17
+ * an artifactId, and nothing keys an artifact by it.
18
+ *
19
+ * Its one production caller is the `jarSignature` field in
20
+ * `src/minecraft-explorer-service.ts`, which publishes this stat-based value to
21
+ * callers of `get-project-context`.
22
+ */
23
+ export declare function readJarStatStamp(jarPath: string): JarStatStamp;
13
24
  export declare function isSecureJarEntryPath(entryPath: string): boolean;
14
25
  /**
15
26
  * Validate and normalize a user-supplied jar path input.
@@ -72,7 +72,18 @@ export function resolveJarPathWithSymlinkCheck(jarPath) {
72
72
  export function buildJarSignature(stats) {
73
73
  return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
74
74
  }
75
- export function artifactSignatureFromFile(jarPath) {
75
+ /**
76
+ * Read a jar's stat stamp: it stats the file, it does not read a byte of it.
77
+ *
78
+ * The name says stat because a stat is all this is. Artifact identity is
79
+ * derived and composed in `src/artifact-identity.ts`; nothing this returns is
80
+ * an artifactId, and nothing keys an artifact by it.
81
+ *
82
+ * Its one production caller is the `jarSignature` field in
83
+ * `src/minecraft-explorer-service.ts`, which publishes this stat-based value to
84
+ * callers of `get-project-context`.
85
+ */
86
+ export function readJarStatStamp(jarPath) {
76
87
  const resolvedPath = resolveJarPathWithSymlinkCheck(jarPath).resolvedPath;
77
88
  const stats = statSync(resolvedPath);
78
89
  return {
@@ -11,13 +11,16 @@
11
11
  * That claim is scoped to this entry point, not to the `downloads/` directory.
12
12
  * Callers that reach for raw {@link downloadToCache} - the version service's
13
13
  * Mojang jars, the mapping service's mapping archives - park files under the
14
- * same root, write no sidecar, and keep their own identity on purpose: a Mojang
15
- * artifact already carries an upstream SHA-1 contract, and a version jar keeps
16
- * an `mtimeMs:size` signature. They are a different cache layer, not a
17
- * migration this module is waiting on. Artifacts on local disk outside the
18
- * cache entirely (`~/.m2`, the Gradle module cache) are identified by their
19
- * bytes as well - see `contentSignature` in `source-resolver.ts`, which reuses
20
- * {@link digestFile} to get there.
14
+ * same root and write no sidecar: a Mojang artifact already carries an upstream
15
+ * SHA-1 contract. They are a different cache layer, not a migration this module
16
+ * is waiting on.
17
+ *
18
+ * Every artifactId `src/artifact-identity.ts` composes, in both of its id
19
+ * spaces, is built on a sha256 of the bytes of the file it names - see
20
+ * `contentSignature` there, which reuses {@link digestFile} to get it. That
21
+ * covers artifacts on local disk outside the cache entirely (`~/.m2`, the
22
+ * Gradle module cache) and a jar or a Minecraft version the caller names
23
+ * directly.
21
24
  */
22
25
  export interface DownloadResult {
23
26
  ok: boolean;
@@ -109,6 +112,15 @@ export interface CachedDownloadFailure {
109
112
  * for a fresh download.
110
113
  */
111
114
  export type CachedDownloadResult = CachedDownloadSuccess | CachedDownloadFailure;
115
+ /**
116
+ * Drop every remembered rejection.
117
+ *
118
+ * The records are process-local and expire on their own, so this exists for a
119
+ * caller that wants the next resolve to ask the repositories again without
120
+ * waiting out {@link REMEMBERED_REJECTION_TTL_MS} - a test isolating itself from
121
+ * the record another test left behind, above all.
122
+ */
123
+ export declare function clearRememberedRejections(): void;
112
124
  /** The persisted identity and freshness record for one cached download. */
113
125
  export interface DownloadSidecar {
114
126
  version: number;
@@ -40,6 +40,96 @@ const DEFINITIVE_REJECTION_STATUS_CODES = new Set([403, 404, 410]);
40
40
  function isDefinitiveRejection(statusCode) {
41
41
  return statusCode !== undefined && DEFINITIVE_REJECTION_STATUS_CODES.has(statusCode);
42
42
  }
43
+ /**
44
+ * How long a definitive rejection is remembered, and how many such records are
45
+ * kept at once.
46
+ *
47
+ * Nothing about a FAILED download is recorded anywhere else: only bytes land in
48
+ * the download cache, so a coordinate whose sources jar does not exist re-issues
49
+ * the same request to every configured repository on every resolve. That is what
50
+ * makes a repeated `target`-addressed resolve expensive - the artifact index it
51
+ * built is reused, but the repository sweep that preceded it is paid again - and
52
+ * a caller whose first call timed out pays the identical sweep on its retry.
53
+ *
54
+ * Only {@link DEFINITIVE_REJECTION_STATUS_CODES} are eligible, because those are
55
+ * the statuses this module already treats as an answer about the artifact rather
56
+ * than about the repository's health; the doc comment there is the reasoning.
57
+ * A 5xx, a 429, a 401, a timeout and a thrown network error are all ineligible,
58
+ * so a passing fault costs one extra request on the next call rather than
59
+ * locking the artifact out of reach.
60
+ *
61
+ * The window bounds even the eligible ones. Records live in memory only, so the
62
+ * whole set dies with the process, and an artifact published after its rejection
63
+ * was recorded is reachable again once the record expires.
64
+ */
65
+ const REMEMBERED_REJECTION_TTL_MS = 5 * 60_000;
66
+ const REMEMBERED_REJECTION_MAX_ENTRIES = 1024;
67
+ /**
68
+ * Definitive rejections, keyed by the cache slot AND the url that was refused.
69
+ *
70
+ * The slot is in the key because it names the cache directory: two configs
71
+ * pointed at different cache directories are different caches and must not
72
+ * inherit each other's verdicts. The url is in the key as well because
73
+ * `destinationPath` is the caller's to choose, so nothing here may assume the
74
+ * slot was derived from the url it is being asked about.
75
+ */
76
+ const rememberedRejections = new Map();
77
+ function rememberedRejectionKey(url, destinationPath) {
78
+ return `${destinationPath}\u0000${url}`;
79
+ }
80
+ /** The unexpired rejection recorded for this url in this slot, if there is one. */
81
+ function readRememberedRejection(url, destinationPath, now) {
82
+ const key = rememberedRejectionKey(url, destinationPath);
83
+ const remembered = rememberedRejections.get(key);
84
+ if (remembered === undefined) {
85
+ return undefined;
86
+ }
87
+ if (remembered.expiresAt <= now) {
88
+ rememberedRejections.delete(key);
89
+ return undefined;
90
+ }
91
+ // A copy, so a caller that mutates what it was handed cannot edit the record
92
+ // every later call is answered from.
93
+ return { ...remembered.result };
94
+ }
95
+ function rememberRejection(url, destinationPath, result, now) {
96
+ const key = rememberedRejectionKey(url, destinationPath);
97
+ // Delete before set, so re-recording a key moves it to the back of the Map's
98
+ // insertion order - which is the order the eviction below reads as recency.
99
+ rememberedRejections.delete(key);
100
+ rememberedRejections.set(key, {
101
+ expiresAt: now + REMEMBERED_REJECTION_TTL_MS,
102
+ result: { ...result }
103
+ });
104
+ if (rememberedRejections.size <= REMEMBERED_REJECTION_MAX_ENTRIES) {
105
+ return;
106
+ }
107
+ // Over the cap. Expired records first - they answer nothing anyway - and then
108
+ // oldest-recorded first until the set fits. Deleting during iteration is
109
+ // defined for a Map: the iterator skips what has already been removed.
110
+ for (const [candidate, entry] of rememberedRejections) {
111
+ if (entry.expiresAt <= now) {
112
+ rememberedRejections.delete(candidate);
113
+ }
114
+ }
115
+ for (const candidate of rememberedRejections.keys()) {
116
+ if (rememberedRejections.size <= REMEMBERED_REJECTION_MAX_ENTRIES) {
117
+ break;
118
+ }
119
+ rememberedRejections.delete(candidate);
120
+ }
121
+ }
122
+ /**
123
+ * Drop every remembered rejection.
124
+ *
125
+ * The records are process-local and expire on their own, so this exists for a
126
+ * caller that wants the next resolve to ask the repositories again without
127
+ * waiting out {@link REMEMBERED_REJECTION_TTL_MS} - a test isolating itself from
128
+ * the record another test left behind, above all.
129
+ */
130
+ export function clearRememberedRejections() {
131
+ rememberedRejections.clear();
132
+ }
43
133
  /** Location of the sidecar describing `destinationPath`. Destination-adjacent. */
44
134
  export function downloadSidecarPath(destinationPath) {
45
135
  return `${destinationPath}${DOWNLOAD_SIDECAR_SUFFIX}`;
@@ -681,13 +771,24 @@ async function cachedBytesResult(url, destinationPath, sidecar, cacheStatus) {
681
771
  * a zip reader opens it - so it must satisfy neither an immutable hit nor a
682
772
  * stale-if-error fallback. Treating it as absent lets the next transfer replace
683
773
  * it instead of pinning it forever.
774
+ *
775
+ * That equivalence stops at "missing", the same line {@link describeFileIfPresent}
776
+ * draws: only {@link isMissingFileError} answers a cache miss. A present-but-
777
+ * unreadable entry (EACCES from a locked-down cache directory, EISDIR, EIO)
778
+ * propagates instead of collapsing to 0, because this is the first stat this
779
+ * module makes on the path - collapsing it here would let `resolveCachedDownload`
780
+ * read "no cached bytes" and fall through to a live transfer without ever
781
+ * reaching the read path that already reports this failure correctly.
684
782
  */
685
783
  function cachedByteCount(filePath) {
686
784
  try {
687
785
  return statSync(filePath).size;
688
786
  }
689
- catch {
690
- return 0;
787
+ catch (error) {
788
+ if (isMissingFileError(error)) {
789
+ return 0;
790
+ }
791
+ throw error;
691
792
  }
692
793
  }
693
794
  /**
@@ -737,6 +838,17 @@ export async function resolveCachedDownload(url, destinationPath, options) {
737
838
  // cache miss, so fall through to the transfer below - failing here would
738
839
  // hand the caller's repository loop an ENOENT as its reason to fail over.
739
840
  }
841
+ if (freshness === "immutable") {
842
+ // A repository's definitive answer about this url, still inside its window.
843
+ // Below the cached-bytes hit above on purpose: bytes we hold outrank a
844
+ // remembered refusal, and the 404 leg further down deliberately keeps the
845
+ // bytes it found. Only immutable urls are ever recorded, so this can never
846
+ // answer for a -SNAPSHOT.
847
+ const remembered = readRememberedRejection(url, destinationPath, Date.now());
848
+ if (remembered !== undefined) {
849
+ return remembered;
850
+ }
851
+ }
740
852
  const conditionalHeaders = freshness === "revalidate" ? conditionalHeadersFor(sidecar) : undefined;
741
853
  /**
742
854
  * Run the transfer, carrying the cached copy's validators only when asked.
@@ -920,13 +1032,21 @@ export async function resolveCachedDownload(url, destinationPath, options) {
920
1032
  // this module's call - and the record goes back with them, so a later
921
1033
  // revalidation that finds the artifact restored still has its validators.
922
1034
  restoreRetiredSidecar();
923
- return {
1035
+ const rejection = {
924
1036
  ok: false,
925
1037
  statusCode: downloaded.statusCode,
926
1038
  etag: downloaded.etag,
927
1039
  lastModified: downloaded.lastModified,
928
1040
  contentLength: downloaded.contentLength
929
1041
  };
1042
+ // Remember it, so the next resolve of this url inside the window answers
1043
+ // from here instead of re-issuing the request. Immutable urls only: a
1044
+ // -SNAPSHOT is republished under the same name, so its 404 can be a
1045
+ // publish in progress rather than an answer that will hold.
1046
+ if (freshness === "immutable") {
1047
+ rememberRejection(url, destinationPath, rejection, Date.now());
1048
+ }
1049
+ return rejection;
930
1050
  }
931
1051
  // The same stale-if-error reuse, for a repository that answered rather than
932
1052
  // failed to: a 5xx, a 429, an authentication hiccup. Nothing here says the