@adhisang/minecraft-modding-mcp 7.0.0-rc.3 → 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.
- package/CHANGELOG.md +13 -0
- package/dist/cache-registry.d.ts +16 -0
- package/dist/cache-registry.js +78 -10
- package/dist/json-rpc-framing.d.ts +20 -0
- package/dist/json-rpc-framing.js +80 -7
- package/dist/mapping/loaders/tiny-maven.d.ts +9 -0
- package/dist/mapping/loaders/tiny-maven.js +10 -2
- package/dist/repo-downloader.js +13 -2
- package/dist/source/class-source.js +56 -1
- package/dist/stdio-supervisor.js +193 -34
- package/dist/storage/db.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [7.0.0] - 2026-09-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Several more ways a fault during the stdio supervisor's own fault-recovery work could leave a request permanently unanswered, or answered twice, are closed. A `validate-project` request could be left marked as still running forever if an internal recovery step for an earlier fault on that same request itself failed, blocking every later `validate-project` call for the rest of the session; that is now cleared first. If a client re-sent `initialize` to a worker that was already ready and the supervisor then faulted while recovering from that duplicate handshake, other requests still in flight on the worker being replaced were not answered and could hang for the rest of the session; they are now answered before the worker is replaced. And a request taken from the internal queue whose own reply step then failed in a narrow way could be left with no reply at all, or, in a different case, answered a second, spurious time; exactly one reply is now produced either way. Each of these requires an internal fault that is not known to occur in current Node.js and has not been observed in production.
|
|
15
|
+
- Two remaining internal call sites that build an error's logged description now use the same safe string conversion already used elsewhere for this reason, so a thrown value whose own string conversion itself throws can no longer slip past them either. Cosmetic; there is no known way to trigger it.
|
|
16
|
+
- Four more ways a fault on the stdio connection to the worker process could leave a request unanswered are closed. A write error to the worker other than a broken pipe used to be logged and otherwise ignored, leaving the connection pointed at a worker nothing could send to again, so every request already sent to it — and every request sent afterward — hung for the rest of the session; that fault now retires the broken worker and starts a replacement, answering the stranded request along the way. If that same fault lands while a still-starting successor worker is replaying the client's `initialize` handshake, the client's `initialize` call now fails immediately instead of waiting silently across every later restart attempt. A successful reply to `initialize` could also be lost outright if writing it to the client failed at the same moment as the fallback log message the supervisor writes about that failure; both are now recovered from together. And once `initialize` completes, a client may legally reuse its request id for a later call; if the worker then exited before answering that reused id, it used to be mistaken for the already-finished `initialize` and silently dropped instead of receiving its own reply.
|
|
17
|
+
- A large request sent in line-delimited framing right after a `Content-Length`-framed one was wrongly rejected as an oversized header, because the 8 KiB header-size ceiling kept applying even once the reader had already moved on to line framing; such a request is now accepted up to the ordinary frame-size limit. Separately, when an oversized line arrived without its terminating newline yet, discarding it did not also consume the rest of that same line once the newline did arrive; the tail could then be re-read as if it were a fresh, legitimate request of its own. That tail is now discarded along with the rest of the line it belongs to.
|
|
18
|
+
- Deleting a downloaded-jar entry with `manage-cache` could destroy a different jar than the one that was listed, when another resolve finished writing a fresh copy to the same cache slot in between; the delete now verifies it is still removing the jar it originally listed. A delete that could not actually remove a file — for example because the cache directory is read-only — is no longer reported as successful; it now returns a warning and the entry is not counted as deleted. Checking a cached download's size no longer treats a permission error the same as a missing file; previously that silently triggered a fresh download instead of surfacing the actionable error.
|
|
19
|
+
- `find-class` now resolves the correct fully qualified name for a class nested two or more levels inside another class. It previously dropped every enclosing type past the first level, producing a name that does not exist among the compiled classes, so a follow-up call using that name — reading its members or source, for example — failed with a spurious not-found error. A class nested exactly one level deep was already correct and is unaffected.
|
|
20
|
+
- An unrecognized failure opening the artifact index database — for example the database being locked or the disk being full — is now reported as the documented database-failure error instead of escaping unclassified. Previously only a failure already recognized as an I/O problem, a schema mismatch, or corruption was reported that way.
|
|
21
|
+
- Loading a Yarn/Tiny mapping file from a Maven-hosted jar is now bounded the same way nested-jar and download extraction already are. A `.tiny` entry inside such a jar could previously be decompressed in full with no size limit, so a small, highly compressed entry could expand to multiple gigabytes and exhaust memory before mapping parsing ever started.
|
|
22
|
+
|
|
10
23
|
## [7.0.0-rc.3] - 2026-09-07
|
|
11
24
|
|
|
12
25
|
### Changed
|
package/dist/cache-registry.d.ts
CHANGED
|
@@ -42,6 +42,22 @@ type CacheEntryPage = {
|
|
|
42
42
|
* while still allowing major.minor sweeps where "1.21" matches "1.21.4".
|
|
43
43
|
*/
|
|
44
44
|
export declare function pathContainsVersion(path: string, version: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The identity `deleteEntries` captures for a downloads-cache jar at listing
|
|
47
|
+
* time, so a prune that only ever looked at the file once cannot destroy a
|
|
48
|
+
* concurrent resolve's freshly-written replacement at the same path.
|
|
49
|
+
*
|
|
50
|
+
* A missing, unreadable, or malformed sidecar answers with no identity to
|
|
51
|
+
* prove - {@link discardCachedDownload} already treats that as licence to
|
|
52
|
+
* evict unconditionally, the "cannot prove otherwise" rule this module's own
|
|
53
|
+
* sidecar-less entries already rely on elsewhere.
|
|
54
|
+
*
|
|
55
|
+
* Exported for direct testing, the same way {@link pathContainsVersion} is.
|
|
56
|
+
*/
|
|
57
|
+
export declare function readDownloadEntryIdentity(jarPath: string): Promise<{
|
|
58
|
+
url: string;
|
|
59
|
+
contentSha256: string;
|
|
60
|
+
} | undefined>;
|
|
45
61
|
export type CacheRegistryConfig = {
|
|
46
62
|
cacheDir: string;
|
|
47
63
|
sqlitePath: string;
|
package/dist/cache-registry.js
CHANGED
|
@@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { mapWithConcurrencyLimit } from "./concurrency.js";
|
|
5
5
|
import { createError, ERROR_CODES } from "./errors.js";
|
|
6
6
|
import { normalizeOptionalPathForHost } from "./path-converter.js";
|
|
7
|
-
import { downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
|
|
7
|
+
import { discardCachedDownload, downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
|
|
8
8
|
import { openDatabase } from "./storage/db.js";
|
|
9
9
|
import { getProcessWorkspaceContextCache } from "./workspace-context-cache.js";
|
|
10
10
|
export const PUBLIC_CACHE_KINDS = [
|
|
@@ -528,6 +528,33 @@ async function downloadSidecarSizeBytes(downloadPath) {
|
|
|
528
528
|
return 0;
|
|
529
529
|
}
|
|
530
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* The identity `deleteEntries` captures for a downloads-cache jar at listing
|
|
533
|
+
* time, so a prune that only ever looked at the file once cannot destroy a
|
|
534
|
+
* concurrent resolve's freshly-written replacement at the same path.
|
|
535
|
+
*
|
|
536
|
+
* A missing, unreadable, or malformed sidecar answers with no identity to
|
|
537
|
+
* prove - {@link discardCachedDownload} already treats that as licence to
|
|
538
|
+
* evict unconditionally, the "cannot prove otherwise" rule this module's own
|
|
539
|
+
* sidecar-less entries already rely on elsewhere.
|
|
540
|
+
*
|
|
541
|
+
* Exported for direct testing, the same way {@link pathContainsVersion} is.
|
|
542
|
+
*/
|
|
543
|
+
export async function readDownloadEntryIdentity(jarPath) {
|
|
544
|
+
try {
|
|
545
|
+
const parsed = JSON.parse(await readFile(downloadSidecarPath(jarPath), "utf8"));
|
|
546
|
+
if (typeof parsed.url === "string" &&
|
|
547
|
+
typeof parsed.contentSha256 === "string" &&
|
|
548
|
+
parsed.contentSha256.length > 0) {
|
|
549
|
+
return { url: parsed.url, contentSha256: parsed.contentSha256 };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// Missing, unreadable, or malformed sidecar: nothing to identify these
|
|
554
|
+
// bytes with.
|
|
555
|
+
}
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
531
558
|
/**
|
|
532
559
|
* Binary-remap cache entries are keyed by the final artifact id even when the
|
|
533
560
|
* on-disk entry is a corrupt final directory or a leftover temp path.
|
|
@@ -682,7 +709,19 @@ export function createCacheRegistry(config) {
|
|
|
682
709
|
async deleteEntries(input) {
|
|
683
710
|
const entries = await collectEntries(input.cacheKinds, input.selector);
|
|
684
711
|
const selectedBytes = entries.reduce((total, entry) => total + entry.sizeBytes, 0);
|
|
712
|
+
const warnings = [];
|
|
713
|
+
const failedEntries = [];
|
|
685
714
|
if (input.executionMode === "apply") {
|
|
715
|
+
// Captured now, against the listing this call just made, rather than
|
|
716
|
+
// re-read right before each unlink below: the identity has to describe
|
|
717
|
+
// what THIS call selected, not whatever a concurrent resolve may have
|
|
718
|
+
// already replaced it with by the time the loop below reaches it.
|
|
719
|
+
const downloadIdentities = new Map();
|
|
720
|
+
for (const entry of entries) {
|
|
721
|
+
if (entry.cacheKind === "downloads") {
|
|
722
|
+
downloadIdentities.set(entry.path, await readDownloadEntryIdentity(entry.path));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
686
725
|
const db = openDb(config);
|
|
687
726
|
try {
|
|
688
727
|
for (const entry of entries) {
|
|
@@ -695,12 +734,40 @@ export function createCacheRegistry(config) {
|
|
|
695
734
|
continue;
|
|
696
735
|
}
|
|
697
736
|
if (entry.cacheKind === "downloads") {
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
// sidecar
|
|
703
|
-
|
|
737
|
+
// Route through the same sidecar-identity check every other
|
|
738
|
+
// eviction of this shared cache goes through, instead of an
|
|
739
|
+
// unconditional unlink that cannot tell a poisoned jar from a
|
|
740
|
+
// concurrent resolve's good one sitting at the same path. This
|
|
741
|
+
// also retires the sidecar, so it goes with the jar exactly as
|
|
742
|
+
// before - but only when the check above says the jar is still
|
|
743
|
+
// the one this call listed. See discardCachedDownload's doc
|
|
744
|
+
// comment in repo-downloader.ts for what the check does and does
|
|
745
|
+
// not close.
|
|
746
|
+
const expectedIdentity = downloadIdentities.get(entry.path);
|
|
747
|
+
discardCachedDownload(entry.path, expectedIdentity);
|
|
748
|
+
// discardCachedDownload swallows a genuine unlink failure
|
|
749
|
+
// (EACCES, EBUSY, a locked file, ...) - documented there as an
|
|
750
|
+
// accepted limitation for its own best-effort callers. That is
|
|
751
|
+
// wrong for this explicit, user-facing delete/prune request:
|
|
752
|
+
// verify the postcondition instead of trusting the call's
|
|
753
|
+
// silence. The jar surviving is only a LEGITIMATE decline when
|
|
754
|
+
// the identity recorded beside it now genuinely differs from
|
|
755
|
+
// what this call captured at listing time - i.e. a concurrent
|
|
756
|
+
// resolve's replacement is sitting there, exactly the case
|
|
757
|
+
// discardCachedDownload itself declines for. A jar that is
|
|
758
|
+
// still present with no such change (or no readable identity at
|
|
759
|
+
// all) means the removal itself failed.
|
|
760
|
+
if (existsSync(entry.path)) {
|
|
761
|
+
const currentIdentity = await readDownloadEntryIdentity(entry.path);
|
|
762
|
+
const declinedForConcurrentReplacement = expectedIdentity !== undefined &&
|
|
763
|
+
currentIdentity !== undefined &&
|
|
764
|
+
currentIdentity.contentSha256 !== expectedIdentity.contentSha256;
|
|
765
|
+
if (!declinedForConcurrentReplacement) {
|
|
766
|
+
warnings.push(`Could not delete cached download (it may be locked or read-only): ${entry.path}`);
|
|
767
|
+
failedEntries.push(entry);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
continue;
|
|
704
771
|
}
|
|
705
772
|
if (existsSync(entry.path)) {
|
|
706
773
|
// Only binary-remap inventory can return directories as entries;
|
|
@@ -713,10 +780,11 @@ export function createCacheRegistry(config) {
|
|
|
713
780
|
db?.close();
|
|
714
781
|
}
|
|
715
782
|
}
|
|
783
|
+
const failedBytes = failedEntries.reduce((total, entry) => total + entry.sizeBytes, 0);
|
|
716
784
|
return {
|
|
717
|
-
deletedEntries: entries.length,
|
|
718
|
-
deletedBytes: selectedBytes,
|
|
719
|
-
warnings
|
|
785
|
+
deletedEntries: entries.length - failedEntries.length,
|
|
786
|
+
deletedBytes: selectedBytes - failedBytes,
|
|
787
|
+
warnings
|
|
720
788
|
};
|
|
721
789
|
},
|
|
722
790
|
async pruneEntries(input) {
|
|
@@ -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
|
/**
|
package/dist/json-rpc-framing.js
CHANGED
|
@@ -40,8 +40,10 @@ const clearIdleTimerHandle = (handle) => {
|
|
|
40
40
|
*
|
|
41
41
|
* Consequence worth stating, because it is load-bearing for the caller: an
|
|
42
42
|
* EXTRA empty line after the terminator is body, not header. The body window
|
|
43
|
-
* then opens on that empty line
|
|
44
|
-
*
|
|
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.
|
|
45
47
|
* All four extra-blank-line shapes behave alike here, which is the point: the
|
|
46
48
|
* reading does not depend on which terminator style the peer chose.
|
|
47
49
|
*
|
|
@@ -171,6 +173,15 @@ export class JsonRpcFrameReader {
|
|
|
171
173
|
awaitedBodyStart = -1;
|
|
172
174
|
idleTimer;
|
|
173
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;
|
|
174
185
|
/**
|
|
175
186
|
* @param options.maxFrameBytes Largest accepted frame; defaults to
|
|
176
187
|
* {@link loadMaxFrameBytes}.
|
|
@@ -206,6 +217,7 @@ export class JsonRpcFrameReader {
|
|
|
206
217
|
this.awaitedFrameEnd = -1;
|
|
207
218
|
this.awaitedBodyStart = -1;
|
|
208
219
|
this.fatal = false;
|
|
220
|
+
this.discardingOversizedLine = false;
|
|
209
221
|
}
|
|
210
222
|
clear() {
|
|
211
223
|
this.clearIdleTimer();
|
|
@@ -216,6 +228,7 @@ export class JsonRpcFrameReader {
|
|
|
216
228
|
this.awaitedFrameEnd = -1;
|
|
217
229
|
this.awaitedBodyStart = -1;
|
|
218
230
|
this.fatal = false;
|
|
231
|
+
this.discardingOversizedLine = false;
|
|
219
232
|
}
|
|
220
233
|
processChunk(chunk, handlers) {
|
|
221
234
|
if (chunk.length === 0 || this.fatal) {
|
|
@@ -242,6 +255,24 @@ export class JsonRpcFrameReader {
|
|
|
242
255
|
this.pendingBytes = 0;
|
|
243
256
|
while (true) {
|
|
244
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
|
+
}
|
|
245
276
|
this.rejectOversizedIncompleteInput();
|
|
246
277
|
if (this.mode === "unknown") {
|
|
247
278
|
const detected = this.detectMode();
|
|
@@ -297,6 +328,7 @@ export class JsonRpcFrameReader {
|
|
|
297
328
|
this.buffer = Buffer.alloc(0);
|
|
298
329
|
this.pendingChunks = [];
|
|
299
330
|
this.pendingBytes = 0;
|
|
331
|
+
this.discardingOversizedLine = false;
|
|
300
332
|
handlers.onError(error);
|
|
301
333
|
return;
|
|
302
334
|
}
|
|
@@ -361,6 +393,28 @@ export class JsonRpcFrameReader {
|
|
|
361
393
|
handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
|
|
362
394
|
this.idleTimer = handle;
|
|
363
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
|
+
}
|
|
364
418
|
canCompleteFrame(chunk) {
|
|
365
419
|
const bufferedBytes = this.buffer.length + this.pendingBytes;
|
|
366
420
|
if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
|
|
@@ -373,8 +427,17 @@ export class JsonRpcFrameReader {
|
|
|
373
427
|
return chunk.includes(0x0a) || bufferedBytes > this.maxFrameBytes;
|
|
374
428
|
}
|
|
375
429
|
rejectOversizedIncompleteInput() {
|
|
376
|
-
|
|
377
|
-
|
|
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 &&
|
|
378
441
|
!headerBoundary &&
|
|
379
442
|
this.buffer.length > MAX_CONTENT_LENGTH_HEADER_BYTES) {
|
|
380
443
|
// No header terminator anywhere in an over-limit header block: there is
|
|
@@ -387,15 +450,25 @@ export class JsonRpcFrameReader {
|
|
|
387
450
|
if (this.buffer.length <= this.maxFrameBytes) {
|
|
388
451
|
return;
|
|
389
452
|
}
|
|
390
|
-
if (
|
|
453
|
+
if (inHeaderAccumulation && headerBoundary) {
|
|
391
454
|
return;
|
|
392
455
|
}
|
|
393
|
-
if (
|
|
456
|
+
if (!inHeaderAccumulation && this.buffer.includes(0x0a)) {
|
|
394
457
|
return;
|
|
395
458
|
}
|
|
396
459
|
const observedBytes = this.buffer.length;
|
|
397
|
-
const description = this.mode === "line"
|
|
460
|
+
const description = this.mode === "line" || isLineFrameAfterContentLength
|
|
461
|
+
? "Line-delimited JSON-RPC frame"
|
|
462
|
+
: "Headerless JSON-RPC input";
|
|
398
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
|
+
}
|
|
399
472
|
throw new Error(`${description} is ${observedBytes} bytes, exceeding the configured frame limit of ` +
|
|
400
473
|
`${this.maxFrameBytes} bytes.`);
|
|
401
474
|
}
|
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
package/dist/repo-downloader.js
CHANGED
|
@@ -771,13 +771,24 @@ async function cachedBytesResult(url, destinationPath, sidecar, cacheStatus) {
|
|
|
771
771
|
* a zip reader opens it - so it must satisfy neither an immutable hit nor a
|
|
772
772
|
* stale-if-error fallback. Treating it as absent lets the next transfer replace
|
|
773
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.
|
|
774
782
|
*/
|
|
775
783
|
function cachedByteCount(filePath) {
|
|
776
784
|
try {
|
|
777
785
|
return statSync(filePath).size;
|
|
778
786
|
}
|
|
779
|
-
catch {
|
|
780
|
-
|
|
787
|
+
catch (error) {
|
|
788
|
+
if (isMissingFileError(error)) {
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
781
792
|
}
|
|
782
793
|
}
|
|
783
794
|
/**
|
|
@@ -9,6 +9,7 @@ import { remapAndCountMembers, sliceMembersWithLimit, projectMembersForWire, pro
|
|
|
9
9
|
import { collectDidYouMeanCandidates } from "./did-you-mean.js";
|
|
10
10
|
import { matchesMemberPattern } from "./member-pattern.js";
|
|
11
11
|
import { findNestedJarClasses, resolveUniqueNestedJarForClass } from "./nested-jars.js";
|
|
12
|
+
import { extractSymbolsFromSource } from "../symbols/symbol-extractor.js";
|
|
12
13
|
import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
|
|
13
14
|
import { dedupeQualityFlags, inheritArtifactMapping, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
|
|
14
15
|
import { isUnobfuscatedVersion } from "../version-service.js";
|
|
@@ -407,6 +408,50 @@ function projectDecompiledFallback(fallback, level) {
|
|
|
407
408
|
// The class-like symbol kinds findClass returns. MUST stay in sync with the JS-side
|
|
408
409
|
// isTypeSymbol checks below; pushed down to SQL so non-type rows are never fetched.
|
|
409
410
|
const TYPE_SYMBOL_KINDS = ["class", "interface", "enum", "record"];
|
|
411
|
+
/**
|
|
412
|
+
* Reconstruct the full nesting chain between a file's top-level type and a
|
|
413
|
+
* type declared two or more levels deeper inside it. The extractor stores
|
|
414
|
+
* only ONE qualifiedName per FILE (the top-level type), so naively
|
|
415
|
+
* concatenating `<topLevelFQN>.<symbolName>` is only correct for exactly one
|
|
416
|
+
* level of nesting — it silently drops every intermediate enclosing type.
|
|
417
|
+
*
|
|
418
|
+
* Walks the file's own type declarations from the top-level type downward,
|
|
419
|
+
* using brace-range containment to find which declaration directly encloses
|
|
420
|
+
* `targetLine` at each level, collecting each intermediate simple name.
|
|
421
|
+
*
|
|
422
|
+
* Returns the intermediate simple names (outermost first, target excluded)
|
|
423
|
+
* or undefined when the chain cannot be reconstructed — e.g. the file's
|
|
424
|
+
* content is unavailable — in which case the caller falls back to the
|
|
425
|
+
* single-level formula.
|
|
426
|
+
*/
|
|
427
|
+
function resolveNestedTypeChain(svc, artifactId, filePath, topSimpleName, targetLine) {
|
|
428
|
+
const fileRow = svc.filesRepo.getFileContent(artifactId, filePath);
|
|
429
|
+
if (!fileRow)
|
|
430
|
+
return undefined;
|
|
431
|
+
const lines = fileRow.content.split(/\r?\n/);
|
|
432
|
+
const fileSymbols = extractSymbolsFromSource(filePath, fileRow.content);
|
|
433
|
+
let currentBody = classSourceHelpers.computeBraceRange(lines, fileSymbols, topSimpleName);
|
|
434
|
+
if (!currentBody)
|
|
435
|
+
return undefined;
|
|
436
|
+
const chain = [];
|
|
437
|
+
// Bounded by the number of type symbols in the file; guards against ever
|
|
438
|
+
// looping on a malformed structure.
|
|
439
|
+
for (let step = 0; step <= fileSymbols.length; step += 1) {
|
|
440
|
+
const childRanges = classSourceHelpers.computeNestedTypeRanges(lines, fileSymbols, currentBody);
|
|
441
|
+
if (childRanges.some((range) => range.declarationLine === targetLine)) {
|
|
442
|
+
return chain;
|
|
443
|
+
}
|
|
444
|
+
const containingChild = childRanges.find((range) => range.declarationLine < targetLine && targetLine <= range.endLine);
|
|
445
|
+
if (!containingChild)
|
|
446
|
+
return undefined;
|
|
447
|
+
const childSymbol = fileSymbols.find((symbol) => symbol.line === containingChild.declarationLine);
|
|
448
|
+
if (!childSymbol)
|
|
449
|
+
return undefined;
|
|
450
|
+
chain.push(childSymbol.symbolName);
|
|
451
|
+
currentBody = containingChild;
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
410
455
|
export function findClass(svc, input) {
|
|
411
456
|
const className = input.className.trim();
|
|
412
457
|
if (!className) {
|
|
@@ -500,8 +545,18 @@ export function findClass(svc, input) {
|
|
|
500
545
|
const enclosingQualifiedName = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
|
|
501
546
|
const enclosingSimpleName = enclosingQualifiedName.split(".").at(-1) ?? enclosingQualifiedName;
|
|
502
547
|
const nested = enclosingSimpleName !== row.symbolName;
|
|
548
|
+
let qualifiedName = nested ? `${enclosingQualifiedName}.${row.symbolName}` : enclosingQualifiedName;
|
|
549
|
+
if (nested) {
|
|
550
|
+
// The formula above assumes exactly one level of nesting. When the type
|
|
551
|
+
// is nested two or more levels deep, reconstruct the real chain so the
|
|
552
|
+
// intermediate enclosing type(s) are not silently dropped.
|
|
553
|
+
const intermediateChain = resolveNestedTypeChain(svc, artifactId, row.filePath, enclosingSimpleName, row.line);
|
|
554
|
+
if (intermediateChain && intermediateChain.length > 0) {
|
|
555
|
+
qualifiedName = `${enclosingQualifiedName}.${[...intermediateChain, row.symbolName].join(".")}`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
503
558
|
candidates.push({
|
|
504
|
-
qualifiedName
|
|
559
|
+
qualifiedName,
|
|
505
560
|
filePath: row.filePath,
|
|
506
561
|
line: row.line,
|
|
507
562
|
symbolKind: row.symbolKind,
|
package/dist/stdio-supervisor.js
CHANGED
|
@@ -1928,8 +1928,10 @@ export class StdioSupervisor {
|
|
|
1928
1928
|
message: detail
|
|
1929
1929
|
});
|
|
1930
1930
|
});
|
|
1931
|
+
const key = requestKey(entry.pending.id);
|
|
1932
|
+
let released = false;
|
|
1933
|
+
let attemptedRelease = false;
|
|
1931
1934
|
this.runRecoveryStep("queue.dispatch_settle", () => {
|
|
1932
|
-
const key = requestKey(entry.pending.id);
|
|
1933
1935
|
// Only THIS instance may be settled here. A fault before forwardRequest
|
|
1934
1936
|
// installed anything leaves the request re-queued by its own no-child
|
|
1935
1937
|
// fallback (a later drain owns it) or already answered by the
|
|
@@ -1937,17 +1939,51 @@ export class StdioSupervisor {
|
|
|
1937
1939
|
// different live request, which keeps its own guarantee.
|
|
1938
1940
|
if (this.pendingRequests.get(key) !== entry.pending)
|
|
1939
1941
|
return;
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
this.writeSyntheticReply(entry.pending, {
|
|
1943
|
-
jsonrpc: "2.0",
|
|
1944
|
-
id: entry.pending.id,
|
|
1945
|
-
error: {
|
|
1946
|
-
code: -32603,
|
|
1947
|
-
message: `MCP supervisor failed to dispatch the queued request: ${detail}`
|
|
1948
|
-
}
|
|
1949
|
-
});
|
|
1942
|
+
attemptedRelease = true;
|
|
1943
|
+
released = this.releaseForwardedRequest(key, entry.pending);
|
|
1950
1944
|
});
|
|
1945
|
+
if (attemptedRelease && !released) {
|
|
1946
|
+
// releaseForwardedRequest deletes the entry and records its tombstone
|
|
1947
|
+
// BEFORE it returns, so a throw part-way through (recordFinalityTombstone's
|
|
1948
|
+
// only throw site today is the debug-log call during tombstone
|
|
1949
|
+
// eviction) can still have taken the id away from its entry. `released`
|
|
1950
|
+
// cannot tell "declined" apart from "threw after mutating", so the id
|
|
1951
|
+
// is read back rather than assumed — mirrors
|
|
1952
|
+
// answerFaultedWorkerResponse's worker_message.release_verify.
|
|
1953
|
+
//
|
|
1954
|
+
// Gated on `attemptedRelease`, not merely `!released`: the identity
|
|
1955
|
+
// check above (`pendingRequests.get(key) !== entry.pending`) returns
|
|
1956
|
+
// early, WITHOUT calling releaseForwardedRequest, whenever this entry
|
|
1957
|
+
// was never installed into pendingRequests to begin with — which is
|
|
1958
|
+
// exactly what happens when forwardRequest's no-child fallback re-queues
|
|
1959
|
+
// this same entry (queue not full) or already answered it itself (queue
|
|
1960
|
+
// full) before throwing later in that same fallback (e.g. from
|
|
1961
|
+
// scheduleRestart). In either of those cases the id is either still
|
|
1962
|
+
// waiting for a real dispatch that will answer it for real later, or
|
|
1963
|
+
// already answered — and this verify step cannot distinguish "never
|
|
1964
|
+
// installed" from "installed, then removed by a throwing release" using
|
|
1965
|
+
// `pendingRequests.has(key)` alone. Running it anyway would record a
|
|
1966
|
+
// spurious tombstone and send a synthetic reply for a request that gets
|
|
1967
|
+
// (or already got) a real one, i.e. two replies for the same id.
|
|
1968
|
+
this.runRecoveryStep("queue.dispatch_settle_verify", () => {
|
|
1969
|
+
if (entry.pending.method === "initialize" || this.pendingRequests.has(key))
|
|
1970
|
+
return;
|
|
1971
|
+
this.recordFinalityTombstone(key, entry.pending.mode);
|
|
1972
|
+
released = true;
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
if (released) {
|
|
1976
|
+
this.runRecoveryStep("queue.dispatch_reply", () => {
|
|
1977
|
+
this.writeSyntheticReply(entry.pending, {
|
|
1978
|
+
jsonrpc: "2.0",
|
|
1979
|
+
id: entry.pending.id,
|
|
1980
|
+
error: {
|
|
1981
|
+
code: -32603,
|
|
1982
|
+
message: `MCP supervisor failed to dispatch the queued request: ${detail}`
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1951
1987
|
return false;
|
|
1952
1988
|
}
|
|
1953
1989
|
}
|
|
@@ -1966,7 +2002,7 @@ export class StdioSupervisor {
|
|
|
1966
2002
|
}
|
|
1967
2003
|
catch (error) {
|
|
1968
2004
|
log("error", "supervisor.worker_spawn_throw", {
|
|
1969
|
-
message:
|
|
2005
|
+
message: describeThrown(error)
|
|
1970
2006
|
});
|
|
1971
2007
|
this.handleStartupFailure(token, { code: null, signal: null });
|
|
1972
2008
|
return;
|
|
@@ -2166,7 +2202,33 @@ export class StdioSupervisor {
|
|
|
2166
2202
|
// cap allows. That successor arms a fresh startup watchdog and, on
|
|
2167
2203
|
// ready, re-forwards the retained initialize (handleWorkerReady), so the
|
|
2168
2204
|
// handshake gets a second chance instead of stalling.
|
|
2205
|
+
//
|
|
2206
|
+
// Replacing the generation does not by itself terminalize any OTHER
|
|
2207
|
+
// request forwarded to the old child: when its `exit` eventually fires,
|
|
2208
|
+
// `handleWorkerExit` finds `this.child` already pointing at the
|
|
2209
|
+
// successor and takes its early-return branch, skipping
|
|
2210
|
+
// failPendingRequestsOnWorkerExit entirely. So that call runs here
|
|
2211
|
+
// first, mirroring the precedent at handleWorkerProcessError, and BEFORE
|
|
2212
|
+
// recoverTimedOutWorker replaces the generation. It does not touch the
|
|
2213
|
+
// retained `initialize` itself: failPendingRequestsOnWorkerExit carves
|
|
2214
|
+
// out `this.initializeRequest`'s key, which is exactly the entry this
|
|
2215
|
+
// lifecycle is about to continue on the successor.
|
|
2216
|
+
//
|
|
2217
|
+
// Split into two independent recovery steps, deliberately: these are two
|
|
2218
|
+
// unrelated effects (terminalizing OTHER stranded requests, and replacing
|
|
2219
|
+
// the generation so the retained initialize gets a second chance), and
|
|
2220
|
+
// they must not share a fault boundary. Before failPendingRequestsOnWorkerExit
|
|
2221
|
+
// existed here, recoverTimedOutWorker was the only statement in this step
|
|
2222
|
+
// and ran unconditionally on any path that reached it. Running both in one
|
|
2223
|
+
// `runRecoveryStep` would let a throw inside failPendingRequestsOnWorkerExit
|
|
2224
|
+
// (e.g. its own timerClearer call faulting for some OTHER pending
|
|
2225
|
+
// request's deadline timer) silently swallow the call to
|
|
2226
|
+
// recoverTimedOutWorker that follows it in the same callback — silently
|
|
2227
|
+
// skipping the one guarantee this whole branch exists to provide.
|
|
2169
2228
|
if (pending.method === "initialize") {
|
|
2229
|
+
this.runRecoveryStep("worker_message.initialize_recovery_fail_pending", () => {
|
|
2230
|
+
this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
|
|
2231
|
+
});
|
|
2170
2232
|
this.runRecoveryStep("worker_message.initialize_recovery", () => {
|
|
2171
2233
|
this.recoverTimedOutWorker();
|
|
2172
2234
|
});
|
|
@@ -2219,6 +2281,37 @@ export class StdioSupervisor {
|
|
|
2219
2281
|
return;
|
|
2220
2282
|
}
|
|
2221
2283
|
log("warn", "supervisor.worker_stdin_error", { message: error.message });
|
|
2284
|
+
// This event fires only on child.stdin, which exists only once the child
|
|
2285
|
+
// has actually been spawned, but that does NOT collapse to a single
|
|
2286
|
+
// "always post-ready" case: handleWorkerReady's legacy-era replay of a
|
|
2287
|
+
// retained `initialize` (era === "legacy" with `this.initializeRequest`
|
|
2288
|
+
// set) forwards it to a freshly spawned successor WITHOUT calling
|
|
2289
|
+
// adoptActiveChild first — adoptActiveChild only runs once that
|
|
2290
|
+
// initialize's response actually comes back, or on the no-replay early
|
|
2291
|
+
// return. So a live child can have `child.stdin` while `this.childReady`
|
|
2292
|
+
// is still false, mirroring the fork handleWorkerProcessError already
|
|
2293
|
+
// makes on `wasReady`. Treating that window as post-ready would run
|
|
2294
|
+
// failPendingRequestsOnWorkerExit, which deliberately carves the retained
|
|
2295
|
+
// initialize's pending entry OUT of what it fails (so a later id reuse of
|
|
2296
|
+
// the completed initialize is not wrongly caught) — leaving the client's
|
|
2297
|
+
// `initialize` answered by nothing and re-replayed against every
|
|
2298
|
+
// successor forever if the stdin fault persists. Before this recovery
|
|
2299
|
+
// existed at all, a broken stdin left `this.child` pointing at a worker
|
|
2300
|
+
// nothing could ever write to again: scheduleRestart's own `this.child`
|
|
2301
|
+
// guard made every later restart attempt a permanent no-op, and any
|
|
2302
|
+
// request already forwarded to this child had nothing left that would
|
|
2303
|
+
// ever answer it.
|
|
2304
|
+
const wasReady = this.childReady;
|
|
2305
|
+
this.invalidateCurrentChild(child);
|
|
2306
|
+
this.beginTreeTermination(child);
|
|
2307
|
+
if (!wasReady) {
|
|
2308
|
+
this.handleStartupFailure(this.attemptToken, { code: null, signal: null });
|
|
2309
|
+
}
|
|
2310
|
+
else {
|
|
2311
|
+
this.consecutiveImmediateStandDowns = 0;
|
|
2312
|
+
this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
|
|
2313
|
+
this.scheduleRestart(true);
|
|
2314
|
+
}
|
|
2222
2315
|
}
|
|
2223
2316
|
/**
|
|
2224
2317
|
* Reassembles the worker's stderr into lines (the ready marker may be split
|
|
@@ -2379,13 +2472,17 @@ export class StdioSupervisor {
|
|
|
2379
2472
|
if (this.isInitializationResponse(message)) {
|
|
2380
2473
|
const id = getTrackedRequestId(message);
|
|
2381
2474
|
let initializeMode;
|
|
2475
|
+
let initializeKey;
|
|
2476
|
+
let initializePending;
|
|
2382
2477
|
if (id !== undefined) {
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2478
|
+
initializeKey = requestKey(id);
|
|
2479
|
+
initializePending = this.pendingRequests.get(initializeKey);
|
|
2480
|
+
initializeMode = initializePending?.mode;
|
|
2386
2481
|
}
|
|
2387
2482
|
initializeMode ??= this.modeForMessage(this.initializeRequest);
|
|
2388
2483
|
if ("error" in message) {
|
|
2484
|
+
if (initializeKey !== undefined)
|
|
2485
|
+
this.pendingRequests.delete(initializeKey);
|
|
2389
2486
|
if (!this.replayingInitialization && id !== undefined) {
|
|
2390
2487
|
this.writeSyntheticReply({ id, era: this.era, mode: initializeMode }, buildLegacyJsonRpcError(id));
|
|
2391
2488
|
const retainedIndex = this.queuedNotifications.findIndex((entry) => isRequest(entry) && requestKey(entry.id) === requestKey(id));
|
|
@@ -2401,6 +2498,8 @@ export class StdioSupervisor {
|
|
|
2401
2498
|
return;
|
|
2402
2499
|
}
|
|
2403
2500
|
if (this.replayingInitialization) {
|
|
2501
|
+
if (initializeKey !== undefined)
|
|
2502
|
+
this.pendingRequests.delete(initializeKey);
|
|
2404
2503
|
this.replayingInitialization = false;
|
|
2405
2504
|
if (this.initializedNotification) {
|
|
2406
2505
|
this.writeToWorker(child, this.initializedNotification);
|
|
@@ -2409,9 +2508,22 @@ export class StdioSupervisor {
|
|
|
2409
2508
|
this.flushQueue();
|
|
2410
2509
|
return;
|
|
2411
2510
|
}
|
|
2511
|
+
// Mark the entry as settling and remove it BEFORE writeToClient, so a
|
|
2512
|
+
// throw there (or in the diagnostic write reporting that throw) still
|
|
2513
|
+
// leaves `settlingWorkerResponse` for `answerUndeliveredWorkerResponse`
|
|
2514
|
+
// to answer this id from later — mirrors the non-initialize response
|
|
2515
|
+
// path below, which does the same for every other successful reply.
|
|
2516
|
+
// Without this, the entry was gone and no marker recorded it, so a
|
|
2517
|
+
// faulted client write here permanently lost the reply to `initialize`.
|
|
2518
|
+
if (initializeKey !== undefined && initializePending) {
|
|
2519
|
+
this.settlingWorkerResponse = { key: initializeKey, snapshot: initializePending };
|
|
2520
|
+
}
|
|
2521
|
+
if (initializeKey !== undefined)
|
|
2522
|
+
this.pendingRequests.delete(initializeKey);
|
|
2412
2523
|
this.clientInitialized = true;
|
|
2413
2524
|
this.adoptActiveChild();
|
|
2414
2525
|
this.writeToClient(message, initializeMode);
|
|
2526
|
+
this.settlingWorkerResponse = undefined;
|
|
2415
2527
|
this.flushQueue();
|
|
2416
2528
|
return;
|
|
2417
2529
|
}
|
|
@@ -2446,13 +2558,19 @@ export class StdioSupervisor {
|
|
|
2446
2558
|
// branch above, never here.
|
|
2447
2559
|
this.settlingWorkerResponse = { key, snapshot: pending };
|
|
2448
2560
|
}
|
|
2449
|
-
|
|
2450
|
-
|
|
2561
|
+
// These two clears run BEFORE timerClearer: a validate-project id must
|
|
2562
|
+
// not stay "running" if the timer clear below throws. answerUndelivered-
|
|
2563
|
+
// WorkerResponse (which later answers this id via settlingWorkerResponse
|
|
2564
|
+
// on a writeToClient/drainQueue fault) never touches these two fields,
|
|
2565
|
+
// so leaving them ordered after a throwing call would strand the
|
|
2566
|
+
// barrier and every validate-project admitted behind it, permanently.
|
|
2451
2567
|
if (pending?.toolName === "validate-project") {
|
|
2452
2568
|
this.runningValidateKey = undefined;
|
|
2453
2569
|
if (this.validateBarrierKey === key)
|
|
2454
2570
|
this.validateBarrierKey = undefined;
|
|
2455
2571
|
}
|
|
2572
|
+
if (pending?.deadlineTimer)
|
|
2573
|
+
this.timerClearer(pending.deadlineTimer);
|
|
2456
2574
|
}
|
|
2457
2575
|
}
|
|
2458
2576
|
this.writeToClient(message, responseMode);
|
|
@@ -2558,7 +2676,12 @@ export class StdioSupervisor {
|
|
|
2558
2676
|
// count to N and falsely escalate retryRecommendation to "report-bug".
|
|
2559
2677
|
const pendingToolNames = [];
|
|
2560
2678
|
for (const [key, pending] of this.pendingRequests.entries()) {
|
|
2561
|
-
|
|
2679
|
+
// Identity, not just id: a client may legally reuse `initialize`'s id
|
|
2680
|
+
// for a later request once initialize has completed. `initializeRequest`
|
|
2681
|
+
// (and so `preservedInitializeKey`) is retained past that point, so a
|
|
2682
|
+
// bare key match would treat the REUSED entry as the still-pending
|
|
2683
|
+
// initialize and skip it here — leaving it answered by nothing.
|
|
2684
|
+
if (key === preservedInitializeKey && pending.method === "initialize")
|
|
2562
2685
|
continue;
|
|
2563
2686
|
pendingToolNames.push(pending.toolName);
|
|
2564
2687
|
}
|
|
@@ -2566,23 +2689,59 @@ export class StdioSupervisor {
|
|
|
2566
2689
|
for (const [toolName, updated] of updatedByTool) {
|
|
2567
2690
|
this.recentRestarts.set(toolName, updated);
|
|
2568
2691
|
}
|
|
2692
|
+
// Each entry's cleanup and its reply run as two SEPARATE recovery steps,
|
|
2693
|
+
// not one bundled try around the whole loop body. Fault containment must
|
|
2694
|
+
// be per-entry, not just per-function: a throw from `this.timerClearer`
|
|
2695
|
+
// (or anything else in the cleanup half) for entry N must not abort the
|
|
2696
|
+
// `for` loop, or every entry after N in Map iteration order is left
|
|
2697
|
+
// completely unprocessed — not answered, and not cleared from
|
|
2698
|
+
// runningValidateKey/validateBarrierKey either. Since the old worker's
|
|
2699
|
+
// own `exit`/`error` handling short-circuits once `this.child` already
|
|
2700
|
+
// points at a successor generation, a request stranded that way here is
|
|
2701
|
+
// stranded permanently, and a stranded validate-project holding the
|
|
2702
|
+
// barrier keys would jam every later validate-project request behind it
|
|
2703
|
+
// forever. Splitting into two steps also means a fault in ONE entry's
|
|
2704
|
+
// cleanup cannot suppress that SAME entry's own reply.
|
|
2569
2705
|
for (const [key, pending] of [...this.pendingRequests.entries()]) {
|
|
2570
|
-
|
|
2706
|
+
// See the identical guard above: a bare key match would also wrongly
|
|
2707
|
+
// skip a request that legally reused the completed initialize's id.
|
|
2708
|
+
if (key === preservedInitializeKey && pending.method === "initialize")
|
|
2571
2709
|
continue;
|
|
2572
|
-
|
|
2573
|
-
this.
|
|
2574
|
-
|
|
2575
|
-
this.
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2710
|
+
this.runRecoveryStep("worker_exit.fail_pending_cleanup", () => {
|
|
2711
|
+
if (this.runningValidateKey === key)
|
|
2712
|
+
this.runningValidateKey = undefined;
|
|
2713
|
+
if (this.validateBarrierKey === key)
|
|
2714
|
+
this.validateBarrierKey = undefined;
|
|
2715
|
+
// Nil the field immediately after clearing, matching the convention
|
|
2716
|
+
// releaseForwardedRequest already uses. writeSyntheticReply (below,
|
|
2717
|
+
// in the SEPARATE fail_pending_reply step) also does
|
|
2718
|
+
// `if (pending.deadlineTimer) this.timerClearer(...)` before it
|
|
2719
|
+
// deletes the entry from pendingRequests. If this step cleared the
|
|
2720
|
+
// timer but left the field set, that second check would still be
|
|
2721
|
+
// true and writeSyntheticReply would attempt a REDUNDANT second
|
|
2722
|
+
// clear of the SAME already-cleared timer. A timerClearer that
|
|
2723
|
+
// faults on a repeat invocation for the same timer would then throw
|
|
2724
|
+
// BEFORE writeSyntheticReply's delete/tombstone — unlike a fault
|
|
2725
|
+
// strictly after deletion (which only loses that one reply), this
|
|
2726
|
+
// would leave the entry live in pendingRequests forever, with
|
|
2727
|
+
// nothing left to remove or answer it. Nilling here makes
|
|
2728
|
+
// writeSyntheticReply's own check false, so it never attempts that
|
|
2729
|
+
// second clear at all.
|
|
2730
|
+
if (pending.deadlineTimer) {
|
|
2731
|
+
this.timerClearer(pending.deadlineTimer);
|
|
2732
|
+
pending.deadlineTimer = undefined;
|
|
2733
|
+
}
|
|
2734
|
+
});
|
|
2735
|
+
this.runRecoveryStep("worker_exit.fail_pending_reply", () => {
|
|
2736
|
+
// Forwarded entries stay in pendingRequests until writeSyntheticReply
|
|
2737
|
+
// settles them (the FORWARDED pending is what entitles the id to a
|
|
2738
|
+
// finality tombstone). Cancelled entries are already gone — the
|
|
2739
|
+
// cancellation settled them terminally at admission.
|
|
2740
|
+
const toolName = pending.toolName ?? "unknown";
|
|
2741
|
+
const pruned = prunedByTool.get(toolName) ?? [];
|
|
2742
|
+
const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
|
|
2743
|
+
this.writeSyntheticReply(pending, reply);
|
|
2744
|
+
});
|
|
2586
2745
|
}
|
|
2587
2746
|
}
|
|
2588
2747
|
/**
|
|
@@ -2688,7 +2847,7 @@ export class StdioSupervisor {
|
|
|
2688
2847
|
}
|
|
2689
2848
|
catch (error) {
|
|
2690
2849
|
this.eventWriter("warn", "supervisor.client_write_error", {
|
|
2691
|
-
message:
|
|
2850
|
+
message: describeThrown(error)
|
|
2692
2851
|
});
|
|
2693
2852
|
}
|
|
2694
2853
|
}
|
package/dist/storage/db.js
CHANGED
|
@@ -135,7 +135,11 @@ export function openDatabase(config, logger = buildDefaultLogger()) {
|
|
|
135
135
|
path: config.sqlitePath,
|
|
136
136
|
reason: errorMessage
|
|
137
137
|
});
|
|
138
|
-
throw
|
|
138
|
+
throw createError({
|
|
139
|
+
code: ERROR_CODES.DB_FAILURE,
|
|
140
|
+
message: `Failed to open SQLite database at ${config.sqlitePath}: ${errorMessage}`,
|
|
141
|
+
details: { sqlitePath: config.sqlitePath, reason: caughtError?.code }
|
|
142
|
+
});
|
|
139
143
|
}
|
|
140
144
|
// The rebuild runs INSIDE the handler for the failure it is recovering
|
|
141
145
|
// from, so it needs a guard of its own: without one a throw from here left
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhisang/minecraft-modding-mcp",
|
|
3
|
-
"version": "7.0.0
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "MCP server for AI-assisted Minecraft modding: inspect decompiled source, resolve Mojang/Yarn/Intermediary mappings, diff versions, analyze Fabric/Forge/NeoForge mod JARs, and validate Mixin, Access Widener, and Access Transformer files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.30.1",
|