@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.0-rc.3
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 +37 -3
- package/README.md +1 -1
- package/dist/artifact-identity.d.ts +130 -0
- package/dist/artifact-identity.js +142 -0
- package/dist/config.js +2 -2
- package/dist/entry-tools/analyze-mod-service.d.ts +4 -4
- package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
- package/dist/entry-tools/entry-tool-schema.d.ts +2 -2
- package/dist/json-rpc-framing.js +77 -18
- package/dist/minecraft-explorer-service.js +2 -2
- package/dist/path-resolver.d.ts +13 -2
- package/dist/path-resolver.js +12 -1
- package/dist/repo-downloader.d.ts +19 -7
- package/dist/repo-downloader.js +110 -1
- package/dist/source/artifact-resolver.js +14 -6
- package/dist/source-resolver.d.ts +0 -1
- package/dist/source-resolver.js +25 -94
- package/dist/stdio-supervisor.d.ts +150 -4
- package/dist/stdio-supervisor.js +528 -45
- package/dist/tool-schemas.d.ts +55 -55
- package/dist/types.d.ts +18 -1
- package/docs/tool-reference.md +24 -12
- package/package.json +1 -1
package/dist/stdio-supervisor.js
CHANGED
|
@@ -181,6 +181,33 @@ export async function settleTreeCleanupWithin(operation, timeoutMs, onTimeout =
|
|
|
181
181
|
operation.then(finish, () => finish(false));
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Describes a thrown value without being able to throw while doing it.
|
|
186
|
+
*
|
|
187
|
+
* The recovery paths in this file start by turning the thrown value into a
|
|
188
|
+
* string for the client's error message. Written inline as
|
|
189
|
+
* `error instanceof Error ? error.message : String(error)`, that conversion
|
|
190
|
+
* runs BEFORE any guard the recovery installs — and it is not safe: `String`
|
|
191
|
+
* calls `toString`, which a thrown object may define to throw, and `.message`
|
|
192
|
+
* may be an accessor that throws (an `Error` subclass, or any object passed
|
|
193
|
+
* through `Object.defineProperty`). A throw there escaped the recovery
|
|
194
|
+
* entirely and reached the frame reader, which drops the frame as a parse
|
|
195
|
+
* error: the request the recovery existed to answer was lost, with no reply,
|
|
196
|
+
* no deadline and its queue still parked.
|
|
197
|
+
*
|
|
198
|
+
* Every step is inside the guard, `instanceof` included — a Proxy can throw
|
|
199
|
+
* from its `getPrototypeOf` trap — and the fallback is a constant, so this
|
|
200
|
+
* function has no throwing path of its own.
|
|
201
|
+
*/
|
|
202
|
+
function describeThrown(error) {
|
|
203
|
+
try {
|
|
204
|
+
const described = error instanceof Error ? error.message : error;
|
|
205
|
+
return typeof described === "string" ? described : String(described);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return "<error description unavailable>";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
184
211
|
function isRequest(message) {
|
|
185
212
|
return "method" in message && "id" in message;
|
|
186
213
|
}
|
|
@@ -677,6 +704,22 @@ export class StdioSupervisor {
|
|
|
677
704
|
*/
|
|
678
705
|
syntheticTombstones = new Map();
|
|
679
706
|
child;
|
|
707
|
+
/**
|
|
708
|
+
* The one response `handleWorkerMessage` is part-way through settling.
|
|
709
|
+
*
|
|
710
|
+
* It removes a response's pending entry BEFORE writing the reply, so between
|
|
711
|
+
* those two statements the id is owed a response that NOTHING records: the
|
|
712
|
+
* entry is gone, no deadline is armed for anything but validate-project, and
|
|
713
|
+
* the worker considers the request answered. A fault in that window used to
|
|
714
|
+
* be indistinguishable from an id already settled, and the request stayed
|
|
715
|
+
* unanswered for the life of an otherwise healthy session.
|
|
716
|
+
*
|
|
717
|
+
* Set the moment the entry is deleted and cleared the moment the write
|
|
718
|
+
* returns, so it is defined only inside that window and only ever names one
|
|
719
|
+
* request. `answerFaultedWorkerResponse` is the sole reader, and it consumes
|
|
720
|
+
* the marker rather than merely reading it.
|
|
721
|
+
*/
|
|
722
|
+
settlingWorkerResponse;
|
|
680
723
|
childReady = false;
|
|
681
724
|
/**
|
|
682
725
|
* Monotonic timestamp of the current generation's adoption, or undefined
|
|
@@ -887,15 +930,20 @@ export class StdioSupervisor {
|
|
|
887
930
|
});
|
|
888
931
|
};
|
|
889
932
|
/**
|
|
890
|
-
* Admission entry point, wrapped so a fault
|
|
933
|
+
* Admission entry point, wrapped so a fault cannot silently drop a request.
|
|
891
934
|
*
|
|
892
935
|
* Anything thrown while classifying, queueing or forwarding a client frame
|
|
893
936
|
* propagates out of the frame reader's `onFrame`, where the reader swallows
|
|
894
937
|
* it as a parse error — the request then gets NO reply and the client waits
|
|
895
938
|
* on that id forever (and on a modern-era request the one-way era lock has
|
|
896
|
-
* already happened).
|
|
897
|
-
*
|
|
898
|
-
*
|
|
939
|
+
* already happened). The catch below answers the id with -32603 instead.
|
|
940
|
+
*
|
|
941
|
+
* The reply is conditional, and on exactly one thing: that nothing this
|
|
942
|
+
* admission installed is still live at the id. A surviving instance is
|
|
943
|
+
* already tracked and will be settled by an ordinary path, so answering
|
|
944
|
+
* alongside it would make two terminal replies for one request; the catch
|
|
945
|
+
* reports and stands down in that case rather than answering. Id-less frames
|
|
946
|
+
* (notifications, malformed ids) have nothing to answer and are only logged.
|
|
899
947
|
*/
|
|
900
948
|
handleClientMessage(message) {
|
|
901
949
|
const admittedId = isRequest(message) ? getTrackedRequestId(message) : undefined;
|
|
@@ -906,47 +954,173 @@ export class StdioSupervisor {
|
|
|
906
954
|
this.routeClientMessage(message);
|
|
907
955
|
}
|
|
908
956
|
catch (error) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
957
|
+
// `describeThrown`, not an inline conversion: this line runs BEFORE
|
|
958
|
+
// every guard below, so a thrown value whose `toString` or `.message`
|
|
959
|
+
// throws would escape the recovery here and cost the request its reply.
|
|
960
|
+
const detail = describeThrown(error);
|
|
961
|
+
// EVERY step below is individually guarded, because this whole block runs
|
|
962
|
+
// inside the frame reader's `onFrame`: a throw escaping here is reported
|
|
963
|
+
// as a parse error and the request is dropped — the exact loss this
|
|
964
|
+
// handler exists to prevent, reintroduced by its own recovery. Guarding
|
|
965
|
+
// per step rather than as a block is what keeps a faulting event writer
|
|
966
|
+
// from costing the client its terminal reply, and a faulting reply from
|
|
967
|
+
// costing the queue its drain.
|
|
968
|
+
//
|
|
969
|
+
// The two writers are separate channels, not one: in the DEFAULT
|
|
970
|
+
// configuration `eventWriter` is `log`, which writes to stderr, while a
|
|
971
|
+
// reply with no injected `clientWriter` goes to stdout. A failure of one
|
|
972
|
+
// is therefore no evidence about the other, in either direction — which
|
|
973
|
+
// is the reason each step carries its own guard rather than the block
|
|
974
|
+
// carrying one.
|
|
975
|
+
this.runRecoveryStep("admission.event", () => {
|
|
976
|
+
this.eventWriter("error", "supervisor.admission_failed", {
|
|
977
|
+
id: "id" in message ? message.id : undefined,
|
|
978
|
+
method: "method" in message ? message.method : undefined,
|
|
979
|
+
message: detail
|
|
980
|
+
});
|
|
914
981
|
});
|
|
915
982
|
// Admission installs state before it can fault (see rollbackFailedAdmission),
|
|
916
983
|
// and the -32603 below is terminal for the id — so anything half-installed
|
|
917
984
|
// has to go before the reply, or it will answer the id a second time.
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
985
|
+
let rolledBack = false;
|
|
986
|
+
this.runRecoveryStep("admission.rollback", () => {
|
|
987
|
+
rolledBack =
|
|
988
|
+
admittedId !== undefined &&
|
|
989
|
+
preexisting !== undefined &&
|
|
990
|
+
this.rollbackFailedAdmission(requestKey(admittedId), preexisting);
|
|
991
|
+
});
|
|
992
|
+
// A rollback that FAULTED may have removed part of the state and left
|
|
993
|
+
// the rest, and it reports neither — so the step above proves only that
|
|
994
|
+
// it did not throw, never that the id is clear. Redo the removal through
|
|
995
|
+
// the primitives themselves rather than through the method that may just
|
|
996
|
+
// have thrown (a fallback that re-enters the failed step is not a
|
|
997
|
+
// fallback), then read the id back. Both halves are idempotent: after a
|
|
998
|
+
// rollback that succeeded they find nothing and change nothing.
|
|
999
|
+
let admissionInstanceSurvives = false;
|
|
1000
|
+
if (admittedId !== undefined && preexisting !== undefined) {
|
|
1001
|
+
const admittedKey = requestKey(admittedId);
|
|
1002
|
+
const scope = preexisting;
|
|
1003
|
+
this.runRecoveryStep("admission.rollback_finality", () => {
|
|
1004
|
+
try {
|
|
1005
|
+
if (this.dropQueuedInstances(admittedKey, scope))
|
|
1006
|
+
rolledBack = true;
|
|
1007
|
+
const live = this.pendingRequests.get(admittedKey);
|
|
1008
|
+
if (live && !scope.has(live) && this.releaseForwardedRequest(admittedKey, live)) {
|
|
1009
|
+
rolledBack = true;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
finally {
|
|
1013
|
+
// In a `finally` because the removal above may itself throw, and a
|
|
1014
|
+
// removal that threw part-way through is exactly the case the read
|
|
1015
|
+
// back has to cover. Should the read ITSELF throw, the step's guard
|
|
1016
|
+
// contains it and the flag stays false, so the reply goes out: a
|
|
1017
|
+
// request answered is the failure this handler exists to prevent,
|
|
1018
|
+
// and it is the safer of the two directions to fail in.
|
|
1019
|
+
admissionInstanceSurvives = this.hasInstanceOutside(admittedKey, scope);
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
921
1023
|
if (!isRequest(message))
|
|
922
1024
|
return;
|
|
923
1025
|
const id = getTrackedRequestId(message);
|
|
924
1026
|
if (id === undefined)
|
|
925
1027
|
return;
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
this.drainQueue();
|
|
942
|
-
}
|
|
943
|
-
catch (drainError) {
|
|
944
|
-
this.eventWriter("error", "supervisor.admission_failed", {
|
|
1028
|
+
if (admissionInstanceSurvives) {
|
|
1029
|
+
// Nothing could take the id away from this admission's own instance,
|
|
1030
|
+
// and that instance still owns it: it is tracked, so the worker's
|
|
1031
|
+
// answer, the worker-exit terminalization or an armed validate-project
|
|
1032
|
+
// deadline will settle it. Adding -32603 on top would be the second
|
|
1033
|
+
// terminal reply for one request, which is the one thing this handler
|
|
1034
|
+
// may never do. It is reported and left alone instead.
|
|
1035
|
+
//
|
|
1036
|
+
// Known limitation: a non-validate tool arms no deadline, so if the
|
|
1037
|
+
// fault also kept the request from reaching the worker, the id waits
|
|
1038
|
+
// for the worker's exit. That is the accepted cost of not adding a
|
|
1039
|
+
// universal per-request deadline; a blanket one would cut off the long
|
|
1040
|
+
// calls this server legitimately makes.
|
|
1041
|
+
this.runRecoveryStep("admission.rollback_incomplete", () => {
|
|
1042
|
+
this.eventWriter("error", "supervisor.admission_rollback_incomplete", {
|
|
945
1043
|
id,
|
|
946
|
-
method: message.method
|
|
947
|
-
message: drainError instanceof Error ? drainError.message : String(drainError)
|
|
1044
|
+
method: message.method
|
|
948
1045
|
});
|
|
949
|
-
}
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
this.runRecoveryStep("admission.reply", () => {
|
|
1050
|
+
this.writeToClient({
|
|
1051
|
+
jsonrpc: "2.0",
|
|
1052
|
+
id,
|
|
1053
|
+
error: {
|
|
1054
|
+
code: -32603,
|
|
1055
|
+
message: `MCP supervisor failed to admit the request: ${detail}`
|
|
1056
|
+
}
|
|
1057
|
+
}, this.modeForMessage(message));
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
if (rolledBack) {
|
|
1061
|
+
// Something WAS removed — which is true on the stand-down branch too,
|
|
1062
|
+
// where a queued instance went but a forwarded one survived. The
|
|
1063
|
+
// released entry may have been the last occupant of a dispatch
|
|
1064
|
+
// barrier, and its cleared deadline was the timer that used to (much
|
|
1065
|
+
// later) unblock the queue behind it. Drain now instead — whether or
|
|
1066
|
+
// not the reply above got out, since a client channel that refused the
|
|
1067
|
+
// reply is no reason to leave the queue parked. A second fault must not
|
|
1068
|
+
// escape either: an escaping throw reaches the frame reader, which
|
|
1069
|
+
// swallows it as a parse error.
|
|
1070
|
+
this.runRecoveryStep("admission.drain", () => {
|
|
1071
|
+
try {
|
|
1072
|
+
this.drainQueue();
|
|
1073
|
+
}
|
|
1074
|
+
catch (drainError) {
|
|
1075
|
+
this.eventWriter("error", "supervisor.admission_failed", {
|
|
1076
|
+
id,
|
|
1077
|
+
method: message.method,
|
|
1078
|
+
message: describeThrown(drainError)
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Runs one step of a fault-recovery path so that it cannot throw.
|
|
1087
|
+
*
|
|
1088
|
+
* The two frame-reader callers — the admission catch in handleClientMessage
|
|
1089
|
+
* and the worker-frame catch in handleWorkerData — execute inside a
|
|
1090
|
+
* {@link JsonRpcFrameReader}'s `onFrame`, where the reader deliberately
|
|
1091
|
+
* converts an escaping throw into a plain parse error: the frame is then
|
|
1092
|
+
* silently dropped, which for a recovery path means the request it was
|
|
1093
|
+
* rescuing is lost after all. `dispatchQueuedRequest` is the third caller,
|
|
1094
|
+
* and its exposure is wider rather than narrower: a drain is reached from
|
|
1095
|
+
* those two `onFrame` paths, but also from timer callbacks
|
|
1096
|
+
* (handleValidateProjectDeadline) and from process-event handlers
|
|
1097
|
+
* (handleWorkerExit), where nothing above it is prepared to contain a throw
|
|
1098
|
+
* at all.
|
|
1099
|
+
*
|
|
1100
|
+
* Reporting goes through `log` rather than the injected `eventWriter` so
|
|
1101
|
+
* that an INJECTED writer — the collaborator most likely to have thrown — is
|
|
1102
|
+
* not also the reporter. That NARROWS the failure; it does not remove it.
|
|
1103
|
+
* `eventWriter` defaults to `log` (see the constructor), so in the default
|
|
1104
|
+
* configuration, where nothing is injected, the reporter IS the writer that
|
|
1105
|
+
* just faulted. The report therefore carries its own try/catch whose handler
|
|
1106
|
+
* does nothing: past this point there is no reporting channel left, and a
|
|
1107
|
+
* throw here would drop the frame this method exists to save.
|
|
1108
|
+
*/
|
|
1109
|
+
runRecoveryStep(step, run) {
|
|
1110
|
+
try {
|
|
1111
|
+
run();
|
|
1112
|
+
}
|
|
1113
|
+
catch (error) {
|
|
1114
|
+
try {
|
|
1115
|
+
// describeThrown cannot throw, so the outer try/catch here guards only
|
|
1116
|
+
// the `log` call itself.
|
|
1117
|
+
log("error", "supervisor.recovery_step_failed", {
|
|
1118
|
+
step,
|
|
1119
|
+
message: describeThrown(error)
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// Deliberately empty — see above. Nothing is left to report through.
|
|
950
1124
|
}
|
|
951
1125
|
}
|
|
952
1126
|
}
|
|
@@ -985,7 +1159,23 @@ export class StdioSupervisor {
|
|
|
985
1159
|
* Returns whether anything was rolled back.
|
|
986
1160
|
*/
|
|
987
1161
|
rollbackFailedAdmission(key, preexisting) {
|
|
988
|
-
let rolledBack =
|
|
1162
|
+
let rolledBack = this.dropQueuedInstances(key, preexisting);
|
|
1163
|
+
const pending = this.pendingRequests.get(key);
|
|
1164
|
+
if (pending && !preexisting.has(pending) && this.releaseForwardedRequest(key, pending)) {
|
|
1165
|
+
rolledBack = true;
|
|
1166
|
+
}
|
|
1167
|
+
return rolledBack;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Removes every QUEUED instance at `key` that is absent from `preexisting`.
|
|
1171
|
+
*
|
|
1172
|
+
* Split out of rollbackFailedAdmission so the admission catch's finality
|
|
1173
|
+
* check can repeat the removal without re-entering the method that may just
|
|
1174
|
+
* have thrown. Returns whether anything was removed; calling it twice is
|
|
1175
|
+
* harmless, because the second call finds nothing.
|
|
1176
|
+
*/
|
|
1177
|
+
dropQueuedInstances(key, preexisting) {
|
|
1178
|
+
let removed = false;
|
|
989
1179
|
for (let index = this.queuedRequests.length - 1; index >= 0; index -= 1) {
|
|
990
1180
|
const entry = this.queuedRequests[index];
|
|
991
1181
|
if (requestKey(entry.pending.id) !== key || preexisting.has(entry.pending))
|
|
@@ -1003,13 +1193,20 @@ export class StdioSupervisor {
|
|
|
1003
1193
|
if (this.validateBarrierKey === key && this.runningValidateKey !== key) {
|
|
1004
1194
|
this.validateBarrierKey = undefined;
|
|
1005
1195
|
}
|
|
1006
|
-
|
|
1196
|
+
removed = true;
|
|
1007
1197
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1198
|
+
return removed;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Whether any request instance live at `key` is absent from `preexisting` —
|
|
1202
|
+
* that is, whether the admission being rolled back still owns its id.
|
|
1203
|
+
*/
|
|
1204
|
+
hasInstanceOutside(key, preexisting) {
|
|
1205
|
+
for (const instance of this.liveInstancesAt(key)) {
|
|
1206
|
+
if (!preexisting.has(instance))
|
|
1207
|
+
return true;
|
|
1011
1208
|
}
|
|
1012
|
-
return
|
|
1209
|
+
return false;
|
|
1013
1210
|
}
|
|
1014
1211
|
routeClientMessage(message) {
|
|
1015
1212
|
debugSupervisor("client_message", {
|
|
@@ -1416,6 +1613,18 @@ export class StdioSupervisor {
|
|
|
1416
1613
|
else {
|
|
1417
1614
|
if (pending.deadlineTimer)
|
|
1418
1615
|
this.timerClearer(pending.deadlineTimer);
|
|
1616
|
+
// Unlike the admission-time queue-limit site, this one runs AFTER
|
|
1617
|
+
// admission may have raised the validate barrier for this very
|
|
1618
|
+
// request, and the terminal reply below is the last event the id will
|
|
1619
|
+
// ever produce — so the barrier has to come down here or nothing
|
|
1620
|
+
// behind it would ever dispatch again. Same guard as every other
|
|
1621
|
+
// release: the barrier may be held by a DIFFERENT, RUNNING request
|
|
1622
|
+
// that reuses this id, and releasing that one would admit concurrent
|
|
1623
|
+
// work alongside a live validate-project.
|
|
1624
|
+
const abandonedKey = requestKey(pending.id);
|
|
1625
|
+
if (this.validateBarrierKey === abandonedKey && this.runningValidateKey !== abandonedKey) {
|
|
1626
|
+
this.validateBarrierKey = undefined;
|
|
1627
|
+
}
|
|
1419
1628
|
this.writeSyntheticReply(pending, buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
|
|
1420
1629
|
}
|
|
1421
1630
|
this.scheduleRestart();
|
|
@@ -1457,8 +1666,12 @@ export class StdioSupervisor {
|
|
|
1457
1666
|
const [{ pending }] = this.queuedRequests.splice(queuedIndex, 1);
|
|
1458
1667
|
if (pending.deadlineTimer)
|
|
1459
1668
|
this.timerClearer(pending.deadlineTimer);
|
|
1460
|
-
|
|
1669
|
+
// A queued entry never owns runningValidateKey. The barrier may be held
|
|
1670
|
+
// by a DIFFERENT, RUNNING request that reuses this id; releasing it here
|
|
1671
|
+
// would admit concurrent work alongside a live validate-project.
|
|
1672
|
+
if (this.validateBarrierKey === key && this.runningValidateKey !== key) {
|
|
1461
1673
|
this.validateBarrierKey = undefined;
|
|
1674
|
+
}
|
|
1462
1675
|
this.drainQueue();
|
|
1463
1676
|
return;
|
|
1464
1677
|
}
|
|
@@ -1651,8 +1864,13 @@ export class StdioSupervisor {
|
|
|
1651
1864
|
return;
|
|
1652
1865
|
}
|
|
1653
1866
|
this.queuedRequests.shift();
|
|
1654
|
-
|
|
1655
|
-
|
|
1867
|
+
// Return only when the dispatch actually happened: a validate-project
|
|
1868
|
+
// runs alone, but one that never reached the worker was settled
|
|
1869
|
+
// instead and released the barrier with it, so the queue behind it is
|
|
1870
|
+
// free to move in the same pass.
|
|
1871
|
+
if (this.dispatchQueuedRequest(next))
|
|
1872
|
+
return;
|
|
1873
|
+
continue;
|
|
1656
1874
|
}
|
|
1657
1875
|
if (this.validateBarrierKey && this.validateBarrierKey !== nextKey) {
|
|
1658
1876
|
const barrierIndex = this.queuedRequests.findIndex((entry) => requestKey(entry.pending.id) === this.validateBarrierKey);
|
|
@@ -1660,7 +1878,77 @@ export class StdioSupervisor {
|
|
|
1660
1878
|
return;
|
|
1661
1879
|
}
|
|
1662
1880
|
this.queuedRequests.shift();
|
|
1663
|
-
this.
|
|
1881
|
+
this.dispatchQueuedRequest(next);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Forwards one queued entry, containing a fault in the forward itself.
|
|
1886
|
+
*
|
|
1887
|
+
* `forwardRequest` installs the pending entry and takes the validate
|
|
1888
|
+
* slot/barrier BEFORE it writes to the worker's stdin, so a throw from the
|
|
1889
|
+
* encode or the write leaves the request holding an id it never reached the
|
|
1890
|
+
* worker on. Unguarded, that request is unreachable: it has been shifted out
|
|
1891
|
+
* of the queue, so no later drain can dispatch it, and no worker will answer
|
|
1892
|
+
* an id it never saw — and only validate-project arms a deadline, so nothing
|
|
1893
|
+
* else settles it either. The one containing guard above this
|
|
1894
|
+
* (`runRecoveryStep` around the recovery drain) contains the exception
|
|
1895
|
+
* without recovering the request, and stops the drain on top of that,
|
|
1896
|
+
* leaving everything behind it parked as well.
|
|
1897
|
+
*
|
|
1898
|
+
* Both are repaired here: the entry is released — which records the finality
|
|
1899
|
+
* tombstone, so a worker that did somehow see the bytes cannot answer over
|
|
1900
|
+
* the reply below — terminally answered, and the drain carries on to the
|
|
1901
|
+
* next entry. Containment is per-entry because the fault this catches is
|
|
1902
|
+
* per-message: an encode failure on one payload, or a stdin `write` that
|
|
1903
|
+
* throws. A stdin that is merely destroyed never reaches this catch at all —
|
|
1904
|
+
* `forwardRequest` tests for that first and takes its own no-child fallback,
|
|
1905
|
+
* which does not throw.
|
|
1906
|
+
*
|
|
1907
|
+
* The `initialize` carve-out in `releaseForwardedRequest` is honoured: if it
|
|
1908
|
+
* declines, nothing is answered here and the handshake lifecycle keeps the
|
|
1909
|
+
* entry. Admission parks `initialize` in `queuedNotifications` rather than in
|
|
1910
|
+
* `queuedRequests`, so the ordinary path never puts one in front of this
|
|
1911
|
+
* drain; `forwardRequest`'s own no-child fallback is the one way an
|
|
1912
|
+
* initialize can end up queued, and the carve-out is what covers it.
|
|
1913
|
+
*
|
|
1914
|
+
* Returns whether the entry reached the worker.
|
|
1915
|
+
*/
|
|
1916
|
+
dispatchQueuedRequest(entry) {
|
|
1917
|
+
try {
|
|
1918
|
+
this.forwardRequest(entry.message, entry.pending);
|
|
1919
|
+
return true;
|
|
1920
|
+
}
|
|
1921
|
+
catch (error) {
|
|
1922
|
+
const detail = describeThrown(error);
|
|
1923
|
+
this.runRecoveryStep("queue.dispatch", () => {
|
|
1924
|
+
this.eventWriter("error", "supervisor.queued_dispatch_failed", {
|
|
1925
|
+
id: entry.pending.id,
|
|
1926
|
+
method: entry.pending.method,
|
|
1927
|
+
toolName: entry.pending.toolName,
|
|
1928
|
+
message: detail
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
this.runRecoveryStep("queue.dispatch_settle", () => {
|
|
1932
|
+
const key = requestKey(entry.pending.id);
|
|
1933
|
+
// Only THIS instance may be settled here. A fault before forwardRequest
|
|
1934
|
+
// installed anything leaves the request re-queued by its own no-child
|
|
1935
|
+
// fallback (a later drain owns it) or already answered by the
|
|
1936
|
+
// queue-limit rejection; either way the id may meanwhile belong to a
|
|
1937
|
+
// different live request, which keeps its own guarantee.
|
|
1938
|
+
if (this.pendingRequests.get(key) !== entry.pending)
|
|
1939
|
+
return;
|
|
1940
|
+
if (!this.releaseForwardedRequest(key, entry.pending))
|
|
1941
|
+
return;
|
|
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
|
+
});
|
|
1950
|
+
});
|
|
1951
|
+
return false;
|
|
1664
1952
|
}
|
|
1665
1953
|
}
|
|
1666
1954
|
spawnWorker() {
|
|
@@ -1723,7 +2011,15 @@ export class StdioSupervisor {
|
|
|
1723
2011
|
return;
|
|
1724
2012
|
reader.processChunk(chunk, {
|
|
1725
2013
|
onFrame: ({ message }) => {
|
|
1726
|
-
|
|
2014
|
+
try {
|
|
2015
|
+
this.handleWorkerMessage(child, message);
|
|
2016
|
+
}
|
|
2017
|
+
catch (error) {
|
|
2018
|
+
// Mirror of the client side's admission safety net (see
|
|
2019
|
+
// handleClientMessage): an unguarded throw here escapes into the
|
|
2020
|
+
// reader, which reports it as a parse error and drops the frame.
|
|
2021
|
+
this.recoverFaultedWorkerMessage(message, error);
|
|
2022
|
+
}
|
|
1727
2023
|
},
|
|
1728
2024
|
onError: (error) => {
|
|
1729
2025
|
if (isJsonRpcFramingFatalError(error)) {
|
|
@@ -1741,6 +2037,181 @@ export class StdioSupervisor {
|
|
|
1741
2037
|
}
|
|
1742
2038
|
});
|
|
1743
2039
|
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Rescues the request a faulted worker frame was answering.
|
|
2042
|
+
*
|
|
2043
|
+
* `handleWorkerMessage` runs inside the worker reader's `onFrame`, where a
|
|
2044
|
+
* throw becomes a parse error and the frame is simply dropped. For a
|
|
2045
|
+
* RESPONSE frame that left the pending entry live with no reply and nothing
|
|
2046
|
+
* left to settle it — deadlines are armed for validate-project only, so any
|
|
2047
|
+
* other tool had no rescue at all and the client waited on that id for the
|
|
2048
|
+
* life of the session. The id is released and answered here instead.
|
|
2049
|
+
*
|
|
2050
|
+
* The queue drain is the OTHER half, and it is why the answering half lives
|
|
2051
|
+
* in its own method: `handleWorkerMessage` deletes a response's pending
|
|
2052
|
+
* entry before the `writeToClient` that can throw, and its `drainQueue()` is
|
|
2053
|
+
* the last statement of all. So the commonest fault arrives here with the
|
|
2054
|
+
* entry already gone AND the queue undrained, and every early return in the
|
|
2055
|
+
* answering path is a case where the drain is the only rescue left.
|
|
2056
|
+
*
|
|
2057
|
+
* What this recovery is worth, stated exactly. It answers the id when this
|
|
2058
|
+
* supervisor is the one entitled to (see `answerFaultedWorkerResponse`), and
|
|
2059
|
+
* every id it answers is tombstoned, so the worker's own answer for that id
|
|
2060
|
+
* cannot become a second reply. It does NOT make one-response-per-id a
|
|
2061
|
+
* property of the whole file: an id whose entry could not be released is
|
|
2062
|
+
* deliberately left unanswered here rather than answered twice, the
|
|
2063
|
+
* `initialize` carve-out is settled by the handshake lifecycle instead, and
|
|
2064
|
+
* tombstone retention is capped at {@link MAX_SYNTHETIC_TOMBSTONES} — past
|
|
2065
|
+
* 1024 live tombstones in one worker generation the oldest is evicted, and a
|
|
2066
|
+
* very old worker's late answer for an evicted id would pass through.
|
|
2067
|
+
*
|
|
2068
|
+
* What this deliberately does NOT do: tear the session down or touch framing
|
|
2069
|
+
* state. One frame the supervisor could not handle is not evidence that the
|
|
2070
|
+
* worker's stream desynchronized (that is `supervisor.worker_framing_fatal`,
|
|
2071
|
+
* reported by the reader itself), and the reader's own state already
|
|
2072
|
+
* describes the stream correctly. The one exception is an in-flight
|
|
2073
|
+
* `initialize`, whose generation cannot finish its handshake once its answer
|
|
2074
|
+
* has been lost — see `answerFaultedWorkerResponse`.
|
|
2075
|
+
*/
|
|
2076
|
+
recoverFaultedWorkerMessage(message, error) {
|
|
2077
|
+
// `describeThrown`, not an inline conversion: this line runs BEFORE every
|
|
2078
|
+
// guard below, so a thrown value whose `toString` or `.message` throws
|
|
2079
|
+
// would escape into the reader and drop the frame this method exists to
|
|
2080
|
+
// rescue.
|
|
2081
|
+
const detail = describeThrown(error);
|
|
2082
|
+
this.runRecoveryStep("worker_message.event", () => {
|
|
2083
|
+
this.eventWriter("error", "supervisor.worker_message_failed", {
|
|
2084
|
+
id: "id" in message ? message.id : undefined,
|
|
2085
|
+
method: "method" in message ? message.method : undefined,
|
|
2086
|
+
message: detail
|
|
2087
|
+
});
|
|
2088
|
+
});
|
|
2089
|
+
this.answerFaultedWorkerResponse(message, detail);
|
|
2090
|
+
// Unconditional, and outside every early return above. Running it when the
|
|
2091
|
+
// ordinary tail-drain would not have is bounded rather than free-handed:
|
|
2092
|
+
// drainQueue returns at once unless the current child is ready with a live
|
|
2093
|
+
// stdin, and dispatches nothing at all while a validate-project is running.
|
|
2094
|
+
//
|
|
2095
|
+
// The guard here contains a drain fault; it does not recover one. What
|
|
2096
|
+
// recovers the request a faulting dispatch stranded is
|
|
2097
|
+
// `dispatchQueuedRequest`, one level down — a request shifted out of the
|
|
2098
|
+
// queue and installed at its id has left every path that could re-reach
|
|
2099
|
+
// it, so containment alone would strand it exactly as badly as the throw
|
|
2100
|
+
// this method exists to answer for.
|
|
2101
|
+
this.runRecoveryStep("worker_message.drain", () => {
|
|
2102
|
+
this.drainQueue();
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Terminally answers the client request a faulted worker RESPONSE frame was
|
|
2107
|
+
* carrying the answer for, if this supervisor is still the one entitled to
|
|
2108
|
+
* answer it. Returns without replying otherwise; the caller's drain runs
|
|
2109
|
+
* either way.
|
|
2110
|
+
*/
|
|
2111
|
+
answerFaultedWorkerResponse(message, detail) {
|
|
2112
|
+
if (!isResponse(message))
|
|
2113
|
+
return;
|
|
2114
|
+
const id = getTrackedRequestId(message);
|
|
2115
|
+
if (id === undefined)
|
|
2116
|
+
return;
|
|
2117
|
+
const key = requestKey(id);
|
|
2118
|
+
const pending = this.pendingRequests.get(key);
|
|
2119
|
+
if (!pending) {
|
|
2120
|
+
// No entry at the id — which is NOT proof the id was answered.
|
|
2121
|
+
// handleWorkerMessage deletes a response's pending entry BEFORE it writes
|
|
2122
|
+
// the reply, so a write that faults arrives here with the entry already
|
|
2123
|
+
// gone and the client still owed a response. That window is the only
|
|
2124
|
+
// thing `settlingWorkerResponse` records, and it is the only case in
|
|
2125
|
+
// which an id with no entry may be answered here; anything else is an id
|
|
2126
|
+
// this supervisor already settled, or one it never tracked.
|
|
2127
|
+
this.answerUndeliveredWorkerResponse(key, detail);
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
let released = false;
|
|
2131
|
+
this.runRecoveryStep("worker_message.release", () => {
|
|
2132
|
+
// releaseForwardedRequest declines an in-flight `initialize` by design:
|
|
2133
|
+
// that entry belongs to the handshake lifecycle.
|
|
2134
|
+
released = this.releaseForwardedRequest(key, pending);
|
|
2135
|
+
});
|
|
2136
|
+
if (!released) {
|
|
2137
|
+
// `released` is false for three different reasons: the `initialize`
|
|
2138
|
+
// carve-out declined, the release threw before it removed anything, or
|
|
2139
|
+
// the release threw AFTER removing the entry. Only the first two leave
|
|
2140
|
+
// the entry owning its id, and the flag cannot tell them apart — the
|
|
2141
|
+
// step is guarded, so any throw leaves it false, and
|
|
2142
|
+
// releaseForwardedRequest deletes the entry and records its tombstone
|
|
2143
|
+
// before it returns. An entry that no longer owns its id has nothing
|
|
2144
|
+
// left that could ever answer it, so the id is read back rather than
|
|
2145
|
+
// assumed.
|
|
2146
|
+
this.runRecoveryStep("worker_message.release_verify", () => {
|
|
2147
|
+
if (pending.method === "initialize" || this.pendingRequests.has(key))
|
|
2148
|
+
return;
|
|
2149
|
+
// Re-recording is idempotent (see recordFinalityTombstone), and covers
|
|
2150
|
+
// a release that threw before it got this far.
|
|
2151
|
+
this.recordFinalityTombstone(key, pending.mode);
|
|
2152
|
+
released = true;
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
if (!released) {
|
|
2156
|
+
// Still live and still owning its id. The `initialize` carve-out leaves
|
|
2157
|
+
// the entry to the handshake lifecycle — but that lifecycle's recovery
|
|
2158
|
+
// paths are not all armed here. The startup watchdog in particular is
|
|
2159
|
+
// cleared by adoptActiveChild, so a client whose `initialize` reached an
|
|
2160
|
+
// ALREADY-READY worker has no watchdog behind it, and a worker that stays
|
|
2161
|
+
// alive triggers neither the exit nor the replay path. What is certain in
|
|
2162
|
+
// every one of those lifecycles is that THIS generation can no longer
|
|
2163
|
+
// finish the handshake: the frame that was lost was its answer. So the
|
|
2164
|
+
// generation is replaced: the current child is invalidated and
|
|
2165
|
+
// terminated, and a successor is spawned — now, or once the live-child
|
|
2166
|
+
// cap allows. That successor arms a fresh startup watchdog and, on
|
|
2167
|
+
// ready, re-forwards the retained initialize (handleWorkerReady), so the
|
|
2168
|
+
// handshake gets a second chance instead of stalling.
|
|
2169
|
+
if (pending.method === "initialize") {
|
|
2170
|
+
this.runRecoveryStep("worker_message.initialize_recovery", () => {
|
|
2171
|
+
this.recoverTimedOutWorker();
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
this.runRecoveryStep("worker_message.reply", () => {
|
|
2177
|
+
this.writeSyntheticReply(pending, {
|
|
2178
|
+
jsonrpc: "2.0",
|
|
2179
|
+
id,
|
|
2180
|
+
error: {
|
|
2181
|
+
code: -32603,
|
|
2182
|
+
message: `MCP supervisor failed to process the worker response: ${detail}`
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Answers a request whose pending entry `handleWorkerMessage` had already
|
|
2189
|
+
* removed when it faulted, and which therefore never reached the client.
|
|
2190
|
+
*
|
|
2191
|
+
* Consumed once: the marker is cleared before the reply is attempted, so a
|
|
2192
|
+
* fault in the reply cannot leave a stale claim on the id for a later,
|
|
2193
|
+
* unrelated fault to act on.
|
|
2194
|
+
*/
|
|
2195
|
+
answerUndeliveredWorkerResponse(key, detail) {
|
|
2196
|
+
const settling = this.settlingWorkerResponse;
|
|
2197
|
+
this.settlingWorkerResponse = undefined;
|
|
2198
|
+
if (!settling || settling.key !== key)
|
|
2199
|
+
return;
|
|
2200
|
+
this.runRecoveryStep("worker_message.undelivered_reply", () => {
|
|
2201
|
+
// The entry is already gone, so writeSyntheticReply will not settle or
|
|
2202
|
+
// tombstone anything; the tombstone is recorded here so the worker
|
|
2203
|
+
// repeating its answer cannot become a second reply.
|
|
2204
|
+
this.recordFinalityTombstone(key, settling.snapshot.mode);
|
|
2205
|
+
this.writeSyntheticReply(settling.snapshot, {
|
|
2206
|
+
jsonrpc: "2.0",
|
|
2207
|
+
id: settling.snapshot.id,
|
|
2208
|
+
error: {
|
|
2209
|
+
code: -32603,
|
|
2210
|
+
message: `MCP supervisor failed to deliver the worker response: ${detail}`
|
|
2211
|
+
}
|
|
2212
|
+
});
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
1744
2215
|
handleWorkerStdinError(child, error) {
|
|
1745
2216
|
if (child !== this.child)
|
|
1746
2217
|
return;
|
|
@@ -1967,6 +2438,14 @@ export class StdioSupervisor {
|
|
|
1967
2438
|
const pending = this.pendingRequests.get(key);
|
|
1968
2439
|
responseMode = pending?.mode;
|
|
1969
2440
|
this.pendingRequests.delete(key);
|
|
2441
|
+
if (pending && pending.method !== "initialize") {
|
|
2442
|
+
// The entry is gone and the client has not been answered yet. From
|
|
2443
|
+
// here until writeToClient returns, this marker is the only record
|
|
2444
|
+
// that the id is still owed a response (see settlingWorkerResponse).
|
|
2445
|
+
// An in-flight initialize is excluded: it is settled by the handshake
|
|
2446
|
+
// branch above, never here.
|
|
2447
|
+
this.settlingWorkerResponse = { key, snapshot: pending };
|
|
2448
|
+
}
|
|
1970
2449
|
if (pending?.deadlineTimer)
|
|
1971
2450
|
this.timerClearer(pending.deadlineTimer);
|
|
1972
2451
|
if (pending?.toolName === "validate-project") {
|
|
@@ -1977,6 +2456,10 @@ export class StdioSupervisor {
|
|
|
1977
2456
|
}
|
|
1978
2457
|
}
|
|
1979
2458
|
this.writeToClient(message, responseMode);
|
|
2459
|
+
// Past this point the reply has been handed to the client channel, so the
|
|
2460
|
+
// id is no longer owed one by the recovery. A fault in the drain below is
|
|
2461
|
+
// the drain's own problem, not an undelivered response.
|
|
2462
|
+
this.settlingWorkerResponse = undefined;
|
|
1980
2463
|
this.drainQueue();
|
|
1981
2464
|
}
|
|
1982
2465
|
applyStageUpdate(params) {
|