@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.
- package/CHANGELOG.md +50 -3
- package/README.md +1 -1
- package/dist/artifact-identity.d.ts +130 -0
- package/dist/artifact-identity.js +142 -0
- package/dist/cache-registry.d.ts +16 -0
- package/dist/cache-registry.js +78 -10
- 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.d.ts +20 -0
- package/dist/json-rpc-framing.js +155 -23
- package/dist/mapping/loaders/tiny-maven.d.ts +9 -0
- package/dist/mapping/loaders/tiny-maven.js +10 -2
- 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 +123 -3
- package/dist/source/artifact-resolver.js +14 -6
- package/dist/source/class-source.js +56 -1
- 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 +710 -68
- package/dist/storage/db.js +5 -1
- 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,113 @@ 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
|
+
const key = requestKey(entry.pending.id);
|
|
1932
|
+
let released = false;
|
|
1933
|
+
let attemptedRelease = false;
|
|
1934
|
+
this.runRecoveryStep("queue.dispatch_settle", () => {
|
|
1935
|
+
// Only THIS instance may be settled here. A fault before forwardRequest
|
|
1936
|
+
// installed anything leaves the request re-queued by its own no-child
|
|
1937
|
+
// fallback (a later drain owns it) or already answered by the
|
|
1938
|
+
// queue-limit rejection; either way the id may meanwhile belong to a
|
|
1939
|
+
// different live request, which keeps its own guarantee.
|
|
1940
|
+
if (this.pendingRequests.get(key) !== entry.pending)
|
|
1941
|
+
return;
|
|
1942
|
+
attemptedRelease = true;
|
|
1943
|
+
released = this.releaseForwardedRequest(key, entry.pending);
|
|
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
|
+
}
|
|
1987
|
+
return false;
|
|
1664
1988
|
}
|
|
1665
1989
|
}
|
|
1666
1990
|
spawnWorker() {
|
|
@@ -1678,7 +2002,7 @@ export class StdioSupervisor {
|
|
|
1678
2002
|
}
|
|
1679
2003
|
catch (error) {
|
|
1680
2004
|
log("error", "supervisor.worker_spawn_throw", {
|
|
1681
|
-
message:
|
|
2005
|
+
message: describeThrown(error)
|
|
1682
2006
|
});
|
|
1683
2007
|
this.handleStartupFailure(token, { code: null, signal: null });
|
|
1684
2008
|
return;
|
|
@@ -1723,7 +2047,15 @@ export class StdioSupervisor {
|
|
|
1723
2047
|
return;
|
|
1724
2048
|
reader.processChunk(chunk, {
|
|
1725
2049
|
onFrame: ({ message }) => {
|
|
1726
|
-
|
|
2050
|
+
try {
|
|
2051
|
+
this.handleWorkerMessage(child, message);
|
|
2052
|
+
}
|
|
2053
|
+
catch (error) {
|
|
2054
|
+
// Mirror of the client side's admission safety net (see
|
|
2055
|
+
// handleClientMessage): an unguarded throw here escapes into the
|
|
2056
|
+
// reader, which reports it as a parse error and drops the frame.
|
|
2057
|
+
this.recoverFaultedWorkerMessage(message, error);
|
|
2058
|
+
}
|
|
1727
2059
|
},
|
|
1728
2060
|
onError: (error) => {
|
|
1729
2061
|
if (isJsonRpcFramingFatalError(error)) {
|
|
@@ -1741,6 +2073,207 @@ export class StdioSupervisor {
|
|
|
1741
2073
|
}
|
|
1742
2074
|
});
|
|
1743
2075
|
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Rescues the request a faulted worker frame was answering.
|
|
2078
|
+
*
|
|
2079
|
+
* `handleWorkerMessage` runs inside the worker reader's `onFrame`, where a
|
|
2080
|
+
* throw becomes a parse error and the frame is simply dropped. For a
|
|
2081
|
+
* RESPONSE frame that left the pending entry live with no reply and nothing
|
|
2082
|
+
* left to settle it — deadlines are armed for validate-project only, so any
|
|
2083
|
+
* other tool had no rescue at all and the client waited on that id for the
|
|
2084
|
+
* life of the session. The id is released and answered here instead.
|
|
2085
|
+
*
|
|
2086
|
+
* The queue drain is the OTHER half, and it is why the answering half lives
|
|
2087
|
+
* in its own method: `handleWorkerMessage` deletes a response's pending
|
|
2088
|
+
* entry before the `writeToClient` that can throw, and its `drainQueue()` is
|
|
2089
|
+
* the last statement of all. So the commonest fault arrives here with the
|
|
2090
|
+
* entry already gone AND the queue undrained, and every early return in the
|
|
2091
|
+
* answering path is a case where the drain is the only rescue left.
|
|
2092
|
+
*
|
|
2093
|
+
* What this recovery is worth, stated exactly. It answers the id when this
|
|
2094
|
+
* supervisor is the one entitled to (see `answerFaultedWorkerResponse`), and
|
|
2095
|
+
* every id it answers is tombstoned, so the worker's own answer for that id
|
|
2096
|
+
* cannot become a second reply. It does NOT make one-response-per-id a
|
|
2097
|
+
* property of the whole file: an id whose entry could not be released is
|
|
2098
|
+
* deliberately left unanswered here rather than answered twice, the
|
|
2099
|
+
* `initialize` carve-out is settled by the handshake lifecycle instead, and
|
|
2100
|
+
* tombstone retention is capped at {@link MAX_SYNTHETIC_TOMBSTONES} — past
|
|
2101
|
+
* 1024 live tombstones in one worker generation the oldest is evicted, and a
|
|
2102
|
+
* very old worker's late answer for an evicted id would pass through.
|
|
2103
|
+
*
|
|
2104
|
+
* What this deliberately does NOT do: tear the session down or touch framing
|
|
2105
|
+
* state. One frame the supervisor could not handle is not evidence that the
|
|
2106
|
+
* worker's stream desynchronized (that is `supervisor.worker_framing_fatal`,
|
|
2107
|
+
* reported by the reader itself), and the reader's own state already
|
|
2108
|
+
* describes the stream correctly. The one exception is an in-flight
|
|
2109
|
+
* `initialize`, whose generation cannot finish its handshake once its answer
|
|
2110
|
+
* has been lost — see `answerFaultedWorkerResponse`.
|
|
2111
|
+
*/
|
|
2112
|
+
recoverFaultedWorkerMessage(message, error) {
|
|
2113
|
+
// `describeThrown`, not an inline conversion: this line runs BEFORE every
|
|
2114
|
+
// guard below, so a thrown value whose `toString` or `.message` throws
|
|
2115
|
+
// would escape into the reader and drop the frame this method exists to
|
|
2116
|
+
// rescue.
|
|
2117
|
+
const detail = describeThrown(error);
|
|
2118
|
+
this.runRecoveryStep("worker_message.event", () => {
|
|
2119
|
+
this.eventWriter("error", "supervisor.worker_message_failed", {
|
|
2120
|
+
id: "id" in message ? message.id : undefined,
|
|
2121
|
+
method: "method" in message ? message.method : undefined,
|
|
2122
|
+
message: detail
|
|
2123
|
+
});
|
|
2124
|
+
});
|
|
2125
|
+
this.answerFaultedWorkerResponse(message, detail);
|
|
2126
|
+
// Unconditional, and outside every early return above. Running it when the
|
|
2127
|
+
// ordinary tail-drain would not have is bounded rather than free-handed:
|
|
2128
|
+
// drainQueue returns at once unless the current child is ready with a live
|
|
2129
|
+
// stdin, and dispatches nothing at all while a validate-project is running.
|
|
2130
|
+
//
|
|
2131
|
+
// The guard here contains a drain fault; it does not recover one. What
|
|
2132
|
+
// recovers the request a faulting dispatch stranded is
|
|
2133
|
+
// `dispatchQueuedRequest`, one level down — a request shifted out of the
|
|
2134
|
+
// queue and installed at its id has left every path that could re-reach
|
|
2135
|
+
// it, so containment alone would strand it exactly as badly as the throw
|
|
2136
|
+
// this method exists to answer for.
|
|
2137
|
+
this.runRecoveryStep("worker_message.drain", () => {
|
|
2138
|
+
this.drainQueue();
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Terminally answers the client request a faulted worker RESPONSE frame was
|
|
2143
|
+
* carrying the answer for, if this supervisor is still the one entitled to
|
|
2144
|
+
* answer it. Returns without replying otherwise; the caller's drain runs
|
|
2145
|
+
* either way.
|
|
2146
|
+
*/
|
|
2147
|
+
answerFaultedWorkerResponse(message, detail) {
|
|
2148
|
+
if (!isResponse(message))
|
|
2149
|
+
return;
|
|
2150
|
+
const id = getTrackedRequestId(message);
|
|
2151
|
+
if (id === undefined)
|
|
2152
|
+
return;
|
|
2153
|
+
const key = requestKey(id);
|
|
2154
|
+
const pending = this.pendingRequests.get(key);
|
|
2155
|
+
if (!pending) {
|
|
2156
|
+
// No entry at the id — which is NOT proof the id was answered.
|
|
2157
|
+
// handleWorkerMessage deletes a response's pending entry BEFORE it writes
|
|
2158
|
+
// the reply, so a write that faults arrives here with the entry already
|
|
2159
|
+
// gone and the client still owed a response. That window is the only
|
|
2160
|
+
// thing `settlingWorkerResponse` records, and it is the only case in
|
|
2161
|
+
// which an id with no entry may be answered here; anything else is an id
|
|
2162
|
+
// this supervisor already settled, or one it never tracked.
|
|
2163
|
+
this.answerUndeliveredWorkerResponse(key, detail);
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
let released = false;
|
|
2167
|
+
this.runRecoveryStep("worker_message.release", () => {
|
|
2168
|
+
// releaseForwardedRequest declines an in-flight `initialize` by design:
|
|
2169
|
+
// that entry belongs to the handshake lifecycle.
|
|
2170
|
+
released = this.releaseForwardedRequest(key, pending);
|
|
2171
|
+
});
|
|
2172
|
+
if (!released) {
|
|
2173
|
+
// `released` is false for three different reasons: the `initialize`
|
|
2174
|
+
// carve-out declined, the release threw before it removed anything, or
|
|
2175
|
+
// the release threw AFTER removing the entry. Only the first two leave
|
|
2176
|
+
// the entry owning its id, and the flag cannot tell them apart — the
|
|
2177
|
+
// step is guarded, so any throw leaves it false, and
|
|
2178
|
+
// releaseForwardedRequest deletes the entry and records its tombstone
|
|
2179
|
+
// before it returns. An entry that no longer owns its id has nothing
|
|
2180
|
+
// left that could ever answer it, so the id is read back rather than
|
|
2181
|
+
// assumed.
|
|
2182
|
+
this.runRecoveryStep("worker_message.release_verify", () => {
|
|
2183
|
+
if (pending.method === "initialize" || this.pendingRequests.has(key))
|
|
2184
|
+
return;
|
|
2185
|
+
// Re-recording is idempotent (see recordFinalityTombstone), and covers
|
|
2186
|
+
// a release that threw before it got this far.
|
|
2187
|
+
this.recordFinalityTombstone(key, pending.mode);
|
|
2188
|
+
released = true;
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
if (!released) {
|
|
2192
|
+
// Still live and still owning its id. The `initialize` carve-out leaves
|
|
2193
|
+
// the entry to the handshake lifecycle — but that lifecycle's recovery
|
|
2194
|
+
// paths are not all armed here. The startup watchdog in particular is
|
|
2195
|
+
// cleared by adoptActiveChild, so a client whose `initialize` reached an
|
|
2196
|
+
// ALREADY-READY worker has no watchdog behind it, and a worker that stays
|
|
2197
|
+
// alive triggers neither the exit nor the replay path. What is certain in
|
|
2198
|
+
// every one of those lifecycles is that THIS generation can no longer
|
|
2199
|
+
// finish the handshake: the frame that was lost was its answer. So the
|
|
2200
|
+
// generation is replaced: the current child is invalidated and
|
|
2201
|
+
// terminated, and a successor is spawned — now, or once the live-child
|
|
2202
|
+
// cap allows. That successor arms a fresh startup watchdog and, on
|
|
2203
|
+
// ready, re-forwards the retained initialize (handleWorkerReady), so the
|
|
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.
|
|
2228
|
+
if (pending.method === "initialize") {
|
|
2229
|
+
this.runRecoveryStep("worker_message.initialize_recovery_fail_pending", () => {
|
|
2230
|
+
this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
|
|
2231
|
+
});
|
|
2232
|
+
this.runRecoveryStep("worker_message.initialize_recovery", () => {
|
|
2233
|
+
this.recoverTimedOutWorker();
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
this.runRecoveryStep("worker_message.reply", () => {
|
|
2239
|
+
this.writeSyntheticReply(pending, {
|
|
2240
|
+
jsonrpc: "2.0",
|
|
2241
|
+
id,
|
|
2242
|
+
error: {
|
|
2243
|
+
code: -32603,
|
|
2244
|
+
message: `MCP supervisor failed to process the worker response: ${detail}`
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Answers a request whose pending entry `handleWorkerMessage` had already
|
|
2251
|
+
* removed when it faulted, and which therefore never reached the client.
|
|
2252
|
+
*
|
|
2253
|
+
* Consumed once: the marker is cleared before the reply is attempted, so a
|
|
2254
|
+
* fault in the reply cannot leave a stale claim on the id for a later,
|
|
2255
|
+
* unrelated fault to act on.
|
|
2256
|
+
*/
|
|
2257
|
+
answerUndeliveredWorkerResponse(key, detail) {
|
|
2258
|
+
const settling = this.settlingWorkerResponse;
|
|
2259
|
+
this.settlingWorkerResponse = undefined;
|
|
2260
|
+
if (!settling || settling.key !== key)
|
|
2261
|
+
return;
|
|
2262
|
+
this.runRecoveryStep("worker_message.undelivered_reply", () => {
|
|
2263
|
+
// The entry is already gone, so writeSyntheticReply will not settle or
|
|
2264
|
+
// tombstone anything; the tombstone is recorded here so the worker
|
|
2265
|
+
// repeating its answer cannot become a second reply.
|
|
2266
|
+
this.recordFinalityTombstone(key, settling.snapshot.mode);
|
|
2267
|
+
this.writeSyntheticReply(settling.snapshot, {
|
|
2268
|
+
jsonrpc: "2.0",
|
|
2269
|
+
id: settling.snapshot.id,
|
|
2270
|
+
error: {
|
|
2271
|
+
code: -32603,
|
|
2272
|
+
message: `MCP supervisor failed to deliver the worker response: ${detail}`
|
|
2273
|
+
}
|
|
2274
|
+
});
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
1744
2277
|
handleWorkerStdinError(child, error) {
|
|
1745
2278
|
if (child !== this.child)
|
|
1746
2279
|
return;
|
|
@@ -1748,6 +2281,37 @@ export class StdioSupervisor {
|
|
|
1748
2281
|
return;
|
|
1749
2282
|
}
|
|
1750
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
|
+
}
|
|
1751
2315
|
}
|
|
1752
2316
|
/**
|
|
1753
2317
|
* Reassembles the worker's stderr into lines (the ready marker may be split
|
|
@@ -1908,13 +2472,17 @@ export class StdioSupervisor {
|
|
|
1908
2472
|
if (this.isInitializationResponse(message)) {
|
|
1909
2473
|
const id = getTrackedRequestId(message);
|
|
1910
2474
|
let initializeMode;
|
|
2475
|
+
let initializeKey;
|
|
2476
|
+
let initializePending;
|
|
1911
2477
|
if (id !== undefined) {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
2478
|
+
initializeKey = requestKey(id);
|
|
2479
|
+
initializePending = this.pendingRequests.get(initializeKey);
|
|
2480
|
+
initializeMode = initializePending?.mode;
|
|
1915
2481
|
}
|
|
1916
2482
|
initializeMode ??= this.modeForMessage(this.initializeRequest);
|
|
1917
2483
|
if ("error" in message) {
|
|
2484
|
+
if (initializeKey !== undefined)
|
|
2485
|
+
this.pendingRequests.delete(initializeKey);
|
|
1918
2486
|
if (!this.replayingInitialization && id !== undefined) {
|
|
1919
2487
|
this.writeSyntheticReply({ id, era: this.era, mode: initializeMode }, buildLegacyJsonRpcError(id));
|
|
1920
2488
|
const retainedIndex = this.queuedNotifications.findIndex((entry) => isRequest(entry) && requestKey(entry.id) === requestKey(id));
|
|
@@ -1930,6 +2498,8 @@ export class StdioSupervisor {
|
|
|
1930
2498
|
return;
|
|
1931
2499
|
}
|
|
1932
2500
|
if (this.replayingInitialization) {
|
|
2501
|
+
if (initializeKey !== undefined)
|
|
2502
|
+
this.pendingRequests.delete(initializeKey);
|
|
1933
2503
|
this.replayingInitialization = false;
|
|
1934
2504
|
if (this.initializedNotification) {
|
|
1935
2505
|
this.writeToWorker(child, this.initializedNotification);
|
|
@@ -1938,9 +2508,22 @@ export class StdioSupervisor {
|
|
|
1938
2508
|
this.flushQueue();
|
|
1939
2509
|
return;
|
|
1940
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);
|
|
1941
2523
|
this.clientInitialized = true;
|
|
1942
2524
|
this.adoptActiveChild();
|
|
1943
2525
|
this.writeToClient(message, initializeMode);
|
|
2526
|
+
this.settlingWorkerResponse = undefined;
|
|
1944
2527
|
this.flushQueue();
|
|
1945
2528
|
return;
|
|
1946
2529
|
}
|
|
@@ -1967,16 +2550,34 @@ export class StdioSupervisor {
|
|
|
1967
2550
|
const pending = this.pendingRequests.get(key);
|
|
1968
2551
|
responseMode = pending?.mode;
|
|
1969
2552
|
this.pendingRequests.delete(key);
|
|
1970
|
-
if (pending
|
|
1971
|
-
|
|
2553
|
+
if (pending && pending.method !== "initialize") {
|
|
2554
|
+
// The entry is gone and the client has not been answered yet. From
|
|
2555
|
+
// here until writeToClient returns, this marker is the only record
|
|
2556
|
+
// that the id is still owed a response (see settlingWorkerResponse).
|
|
2557
|
+
// An in-flight initialize is excluded: it is settled by the handshake
|
|
2558
|
+
// branch above, never here.
|
|
2559
|
+
this.settlingWorkerResponse = { key, snapshot: pending };
|
|
2560
|
+
}
|
|
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.
|
|
1972
2567
|
if (pending?.toolName === "validate-project") {
|
|
1973
2568
|
this.runningValidateKey = undefined;
|
|
1974
2569
|
if (this.validateBarrierKey === key)
|
|
1975
2570
|
this.validateBarrierKey = undefined;
|
|
1976
2571
|
}
|
|
2572
|
+
if (pending?.deadlineTimer)
|
|
2573
|
+
this.timerClearer(pending.deadlineTimer);
|
|
1977
2574
|
}
|
|
1978
2575
|
}
|
|
1979
2576
|
this.writeToClient(message, responseMode);
|
|
2577
|
+
// Past this point the reply has been handed to the client channel, so the
|
|
2578
|
+
// id is no longer owed one by the recovery. A fault in the drain below is
|
|
2579
|
+
// the drain's own problem, not an undelivered response.
|
|
2580
|
+
this.settlingWorkerResponse = undefined;
|
|
1980
2581
|
this.drainQueue();
|
|
1981
2582
|
}
|
|
1982
2583
|
applyStageUpdate(params) {
|
|
@@ -2075,7 +2676,12 @@ export class StdioSupervisor {
|
|
|
2075
2676
|
// count to N and falsely escalate retryRecommendation to "report-bug".
|
|
2076
2677
|
const pendingToolNames = [];
|
|
2077
2678
|
for (const [key, pending] of this.pendingRequests.entries()) {
|
|
2078
|
-
|
|
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")
|
|
2079
2685
|
continue;
|
|
2080
2686
|
pendingToolNames.push(pending.toolName);
|
|
2081
2687
|
}
|
|
@@ -2083,23 +2689,59 @@ export class StdioSupervisor {
|
|
|
2083
2689
|
for (const [toolName, updated] of updatedByTool) {
|
|
2084
2690
|
this.recentRestarts.set(toolName, updated);
|
|
2085
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.
|
|
2086
2705
|
for (const [key, pending] of [...this.pendingRequests.entries()]) {
|
|
2087
|
-
|
|
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")
|
|
2088
2709
|
continue;
|
|
2089
|
-
|
|
2090
|
-
this.
|
|
2091
|
-
|
|
2092
|
-
this.
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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
|
+
});
|
|
2103
2745
|
}
|
|
2104
2746
|
}
|
|
2105
2747
|
/**
|
|
@@ -2205,7 +2847,7 @@ export class StdioSupervisor {
|
|
|
2205
2847
|
}
|
|
2206
2848
|
catch (error) {
|
|
2207
2849
|
this.eventWriter("warn", "supervisor.client_write_error", {
|
|
2208
|
-
message:
|
|
2850
|
+
message: describeThrown(error)
|
|
2209
2851
|
});
|
|
2210
2852
|
}
|
|
2211
2853
|
}
|