@adhisang/minecraft-modding-mcp 6.2.0 → 7.0.0-rc.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 +71 -0
- package/README.md +21 -5
- package/dist/cache-policy.d.ts +71 -0
- package/dist/cache-policy.js +83 -0
- package/dist/cache-registry.js +6 -6
- package/dist/cli.js +74 -3
- package/dist/compat-stdio-transport.d.ts +1 -1
- package/dist/compat-stdio-transport.js +13 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +8 -2
- package/dist/decompiler/vineflower.d.ts +1 -0
- package/dist/decompiler/vineflower.js +8 -5
- package/dist/entry-tools/analyze-mod-service.d.ts +70 -136
- package/dist/entry-tools/analyze-symbol-service.d.ts +112 -150
- package/dist/entry-tools/compare-minecraft-service.d.ts +59 -145
- package/dist/entry-tools/entry-tool-schema.d.ts +38 -4
- package/dist/entry-tools/entry-tool-schema.js +4 -1
- package/dist/entry-tools/inspect-minecraft/internal.d.ts +235 -799
- package/dist/entry-tools/inspect-minecraft/internal.js +65 -28
- package/dist/entry-tools/inspect-minecraft-service.d.ts +372 -1736
- package/dist/entry-tools/inspect-minecraft-service.js +1 -1
- package/dist/entry-tools/manage-cache-service.d.ts +81 -91
- package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
- package/dist/entry-tools/validate-project-service.d.ts +164 -592
- package/dist/entry-tools/verify-mixin-target-service.d.ts +3 -19
- package/dist/era-classifier.d.ts +161 -0
- package/dist/era-classifier.js +292 -0
- package/dist/error-mapping.js +9 -2
- package/dist/index.d.ts +42 -4
- package/dist/index.js +637 -475
- package/dist/java-process.d.ts +2 -0
- package/dist/java-process.js +22 -2
- package/dist/json-rpc-framing.d.ts +77 -1
- package/dist/json-rpc-framing.js +249 -13
- package/dist/mapping/loaders/tiny-loom-selection.d.ts +88 -0
- package/dist/mapping/loaders/tiny-loom-selection.js +223 -0
- package/dist/mapping/loaders/tiny-loom.js +45 -33
- package/dist/mapping/loaders/tiny-maven.js +6 -11
- package/dist/mapping/parsers/tiny.d.ts +57 -0
- package/dist/mapping/parsers/tiny.js +99 -22
- package/dist/mapping-service.d.ts +19 -0
- package/dist/mapping-service.js +93 -9
- package/dist/mcp-helpers.d.ts +19 -2
- package/dist/mcp-helpers.js +48 -6
- package/dist/minecraft-explorer-service.d.ts +1 -1
- package/dist/mixin/types.d.ts +8 -0
- package/dist/mod-analyzer.js +7 -7
- package/dist/mod-decompile-service.js +1 -0
- package/dist/nbt/java-nbt-codec.js +12 -2
- package/dist/nbt/json-patch.js +14 -3
- package/dist/nbt/pipeline.js +40 -3
- package/dist/nbt/typed-json.js +26 -1
- package/dist/registration-adapter.d.ts +32 -0
- package/dist/registration-adapter.js +52 -0
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +9 -0
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +25 -19
- package/dist/server-identity.d.ts +27 -0
- package/dist/server-identity.js +26 -0
- package/dist/source/access-validate.js +53 -0
- package/dist/source/artifact-resolver.d.ts +69 -1
- package/dist/source/artifact-resolver.js +215 -14
- package/dist/source/class-source.d.ts +22 -0
- package/dist/source/class-source.js +162 -29
- package/dist/source/did-you-mean.d.ts +12 -1
- package/dist/source/did-you-mean.js +6 -2
- package/dist/source/file-access.js +150 -46
- package/dist/source/indexer.js +1 -0
- package/dist/source/nested-jars.d.ts +19 -0
- package/dist/source/nested-jars.js +90 -21
- package/dist/source/shared-utils.d.ts +21 -0
- package/dist/source/shared-utils.js +23 -0
- package/dist/source-service.d.ts +12 -0
- package/dist/source-service.js +3 -0
- package/dist/stdio-supervisor.d.ts +357 -2
- package/dist/stdio-supervisor.js +1031 -80
- package/dist/storage/db.d.ts +2 -1
- package/dist/storage/db.js +15 -8
- package/dist/synthetic-decorator.d.ts +24 -0
- package/dist/synthetic-decorator.js +48 -0
- package/dist/tool-contract-manifest.js +1 -1
- package/dist/tool-guidance.d.ts +17 -1
- package/dist/tool-guidance.js +417 -13
- package/dist/tool-schema-registry.d.ts +2 -0
- package/dist/tool-schema-registry.js +4 -0
- package/dist/tool-schemas.d.ts +2214 -3915
- package/dist/tool-schemas.js +34 -7
- package/dist/types.d.ts +35 -0
- package/dist/v1-parity-schemas.d.ts +7 -0
- package/dist/v1-parity-schemas.js +5584 -0
- package/dist/version-diff-service.d.ts +33 -0
- package/dist/version-diff-service.js +148 -3
- package/dist/version-service.js +36 -14
- package/dist/warning-details.js +18 -1
- package/docs/README-ja.md +9 -3
- package/docs/tool-reference.md +267 -22
- package/package.json +12 -9
package/dist/stdio-supervisor.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { encodeJsonRpcMessage, JsonRpcFrameReader } from "./json-rpc-framing.js";
|
|
3
|
+
import { encodeJsonRpcMessage, isJsonRpcFramingFatalError, JsonRpcFrameReader } from "./json-rpc-framing.js";
|
|
4
4
|
import { log } from "./logger.js";
|
|
5
5
|
import { buildSuggestedCall } from "./build-suggested-call.js";
|
|
6
|
-
import { getToolSchema } from "./tool-schema-registry.js";
|
|
6
|
+
import { getToolSchema, registeredToolCount } from "./tool-schema-registry.js";
|
|
7
|
+
import { buildEraConflictRejection, buildInvalidInitializeRejection, buildMethodNotFoundRejection, buildMissingMetaRejection, buildUnsupportedProtocolVersionRejection, classifyEraSignal, extractModernRequestContext, isCompleteInitializeRequest, stripModernEraClaimInPlace, MODERN_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from "./era-classifier.js";
|
|
8
|
+
import { decorateSyntheticReply } from "./synthetic-decorator.js";
|
|
7
9
|
const DEFAULT_CLIENT_MODE = "line";
|
|
8
10
|
const WORKER_MODE_ENV = "MCP_STDIO_WORKER_MODE";
|
|
9
11
|
const WORKER_READY_MARKER = "__MCP_STDIO_WORKER_READY__";
|
|
@@ -21,7 +23,38 @@ const MIN_VALIDATE_PROJECT_TIMEOUT_MS = 10_000;
|
|
|
21
23
|
const MAX_VALIDATE_PROJECT_TIMEOUT_MS = 600_000;
|
|
22
24
|
const MAX_WORKER_STARTUP_WATCHDOG_MS = 30_000;
|
|
23
25
|
const MAX_SUPERVISOR_QUEUE = 2;
|
|
26
|
+
/**
|
|
27
|
+
* Hard ceiling on retained response-finality tombstones (see
|
|
28
|
+
* syntheticTombstones). Retention is normally bounded per worker generation,
|
|
29
|
+
* but a client can synthesize terminal outcomes (queue overflow, cancellation)
|
|
30
|
+
* for unlimited fresh ids INSIDE one healthy generation, so the map also
|
|
31
|
+
* evicts in insertion order once it reaches this size. Losing the oldest
|
|
32
|
+
* tombstone can at worst let a very old late worker answer through, which is
|
|
33
|
+
* strictly preferable to unbounded supervisor memory.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_SYNTHETIC_TOMBSTONES = 1024;
|
|
36
|
+
/**
|
|
37
|
+
* Methods the supervisor answers `-32601 Method not found` at admission in
|
|
38
|
+
* every NON-legacy state. `subscriptions/listen` is auto-provided by the SDK
|
|
39
|
+
* stdio entry with zero registration, so the worker would otherwise ACCEPT it
|
|
40
|
+
* and answer with an id-less acknowledgment notification, never settling the
|
|
41
|
+
* request id (see docs/tool-reference.md, "Absent modern surfaces"). The
|
|
42
|
+
* legacy era needs no entry: nothing registers the method there, so the
|
|
43
|
+
* worker's own registry answers -32601.
|
|
44
|
+
*/
|
|
45
|
+
const MODERN_UNSUPPORTED_METHODS = new Set(["subscriptions/listen"]);
|
|
24
46
|
const DEFAULT_TREE_CLEANUP_TIMEOUT_MS = 5_000;
|
|
47
|
+
/**
|
|
48
|
+
* How long a fatal-error teardown may run before the process is ended anyway.
|
|
49
|
+
*
|
|
50
|
+
* Registering `uncaughtException`/`unhandledRejection` handlers suppresses
|
|
51
|
+
* node's default abort, so the non-zero exit has to be performed explicitly.
|
|
52
|
+
* Cleanup gets this long to reap the worker's process group first; a teardown
|
|
53
|
+
* that stalls past it must not be allowed to keep a crashed supervisor alive.
|
|
54
|
+
*/
|
|
55
|
+
const FATAL_SHUTDOWN_WATCHDOG_MS = 10_000;
|
|
56
|
+
const DEFAULT_CLEANUP_TOKEN_RETRY_BASE_MS = 1_000;
|
|
57
|
+
const DEFAULT_CLEANUP_TOKEN_RETRY_CAP_MS = 30_000;
|
|
25
58
|
export function loadValidateProjectTimeoutMs(value = process.env.MCP_VALIDATE_PROJECT_TIMEOUT_MS) {
|
|
26
59
|
if (!/^[0-9]+$/.test(value ?? "")) {
|
|
27
60
|
return DEFAULT_VALIDATE_PROJECT_TIMEOUT_MS;
|
|
@@ -43,6 +76,22 @@ export function computeRestartBackoffMs(retryIndex) {
|
|
|
43
76
|
}
|
|
44
77
|
return Math.min(100 * (2 ** Math.floor(retryIndex)), MAX_WORKER_STARTUP_WATCHDOG_MS);
|
|
45
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* How long an adopted worker must stay ready before a CLEAN voluntary
|
|
81
|
+
* stand-down counts as the end of a generation that actually served.
|
|
82
|
+
*
|
|
83
|
+
* A worker stands down on stdin EOF. On a host that hands every spawned child
|
|
84
|
+
* a stdin which closes immediately, that EOF arrives while the worker is still
|
|
85
|
+
* starting: it writes its ready marker, is adopted (which clears the restart
|
|
86
|
+
* backoff), and exits 0 in the same breath. Answering that with the 100 ms
|
|
87
|
+
* restart floor respawns a ~125 MB process ten times a second forever, so a
|
|
88
|
+
* stand-down inside this window escalates the backoff instead.
|
|
89
|
+
*
|
|
90
|
+
* A CRASH is deliberately not covered: it may be transient and keeps the
|
|
91
|
+
* prompt replacement the restart suites pin, whereas a clean stand-down means
|
|
92
|
+
* the host closed the pipe and will close the replacement's stdin the same way.
|
|
93
|
+
*/
|
|
94
|
+
export const MIN_HEALTHY_WORKER_READY_MS = 1_000;
|
|
46
95
|
export function terminatePosixProcessGroup(pid, kill = process.kill) {
|
|
47
96
|
try {
|
|
48
97
|
kill(-pid, "SIGKILL");
|
|
@@ -76,6 +125,20 @@ export class RestartBackoffState {
|
|
|
76
125
|
reset() {
|
|
77
126
|
this.retryIndex = 0;
|
|
78
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Raise the escalation level without reserving a restart.
|
|
130
|
+
*
|
|
131
|
+
* Adoption resets the index, so a generation that was adopted and then died
|
|
132
|
+
* without ever serving arrives here with the level already cleared. This
|
|
133
|
+
* restores the level that the repeated failure has earned, which is what
|
|
134
|
+
* stops a self-cancelling adopt/reset cycle from pinning every restart to
|
|
135
|
+
* the 100 ms floor.
|
|
136
|
+
*/
|
|
137
|
+
escalateTo(level) {
|
|
138
|
+
if (!Number.isFinite(level) || level <= 0)
|
|
139
|
+
return;
|
|
140
|
+
this.retryIndex = Math.max(this.retryIndex, Math.floor(level));
|
|
141
|
+
}
|
|
79
142
|
}
|
|
80
143
|
export function retryPosixTreeToken(pid, kill = process.kill) {
|
|
81
144
|
return terminatePosixProcessGroup(pid, kill);
|
|
@@ -109,6 +172,9 @@ function isNotification(message) {
|
|
|
109
172
|
function isResponse(message) {
|
|
110
173
|
return !("method" in message) && "id" in message;
|
|
111
174
|
}
|
|
175
|
+
function isToolArgumentsRecord(value) {
|
|
176
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
177
|
+
}
|
|
112
178
|
function getTrackedRequestId(message) {
|
|
113
179
|
return typeof message.id === "string" || typeof message.id === "number"
|
|
114
180
|
? message.id
|
|
@@ -117,6 +183,49 @@ function getTrackedRequestId(message) {
|
|
|
117
183
|
function requestKey(id) {
|
|
118
184
|
return `${typeof id}:${String(id)}`;
|
|
119
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Frozen premigration reply for a tools/call whose name is not registered
|
|
188
|
+
* (typo OR flag-disabled tool — indistinguishable, exactly as the v1 SDK
|
|
189
|
+
* answered): a SUCCESSFUL CallToolResult with isError:true and one text
|
|
190
|
+
* content entry embedding the v1 error string. The v2 SDK throws a raw
|
|
191
|
+
* -32602 instead, so the supervisor synthesizes this envelope for
|
|
192
|
+
* LEGACY-era registry misses (see handleClientMessage) to keep the legacy
|
|
193
|
+
* wire byte-identical to the premigration baseline
|
|
194
|
+
* (tests/fixtures/premigration/error-code-inventory.json, disabled-tool
|
|
195
|
+
* row). The modern era keeps the raw -32602 (sanctioned: tool absence is a
|
|
196
|
+
* params problem on 2026-07-28, mirroring the SDK's own not-found mapping).
|
|
197
|
+
*/
|
|
198
|
+
export function buildUnknownToolNotFoundReply(id, toolName) {
|
|
199
|
+
return {
|
|
200
|
+
jsonrpc: "2.0",
|
|
201
|
+
id,
|
|
202
|
+
result: {
|
|
203
|
+
content: [{ type: "text", text: `MCP error -32602: Tool ${toolName} not found` }],
|
|
204
|
+
isError: true
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function buildLegacyMalformedToolArgumentsReply(id, argumentsValue) {
|
|
209
|
+
const received = argumentsValue === null
|
|
210
|
+
? "null"
|
|
211
|
+
: Array.isArray(argumentsValue)
|
|
212
|
+
? "array"
|
|
213
|
+
: typeof argumentsValue;
|
|
214
|
+
const message = JSON.stringify([
|
|
215
|
+
{
|
|
216
|
+
code: "invalid_type",
|
|
217
|
+
expected: "object",
|
|
218
|
+
received,
|
|
219
|
+
path: ["params", "arguments"],
|
|
220
|
+
message: `Expected object, received ${received}`
|
|
221
|
+
}
|
|
222
|
+
], null, 2);
|
|
223
|
+
return {
|
|
224
|
+
jsonrpc: "2.0",
|
|
225
|
+
id,
|
|
226
|
+
error: { code: -32603, message }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
120
229
|
export function buildLegacyJsonRpcError(id) {
|
|
121
230
|
return {
|
|
122
231
|
jsonrpc: "2.0",
|
|
@@ -489,6 +598,8 @@ export class StdioSupervisor {
|
|
|
489
598
|
clientWriter;
|
|
490
599
|
treeTokenRetrier;
|
|
491
600
|
treeCleanupTimeoutMs;
|
|
601
|
+
cleanupTokenRetryBaseMs;
|
|
602
|
+
cleanupTokenRetryCapMs;
|
|
492
603
|
eventWriter;
|
|
493
604
|
monotonicNow;
|
|
494
605
|
timerScheduler;
|
|
@@ -507,10 +618,59 @@ export class StdioSupervisor {
|
|
|
507
618
|
unresolvedTreeTokens = new Set();
|
|
508
619
|
restartBackoff = new RestartBackoffState();
|
|
509
620
|
terminalChildren = new WeakSet();
|
|
621
|
+
/**
|
|
622
|
+
* Monotonic worker-generation counter (incremented per successful spawn).
|
|
623
|
+
* Recorded into finality tombstones so retention can be bounded per
|
|
624
|
+
* generation; hand-injected children in tests that bypass spawnWorker run
|
|
625
|
+
* as generation 0.
|
|
626
|
+
*/
|
|
627
|
+
workerGeneration = 0;
|
|
628
|
+
/**
|
|
629
|
+
* Response-finality tombstones: request key → {originating worker
|
|
630
|
+
* generation, originating framing mode}, recorded by writeSyntheticReply
|
|
631
|
+
* the moment a synthetic response is written. While a tombstone exists, any
|
|
632
|
+
* worker RESPONSE for that id is discarded (with a logged
|
|
633
|
+
* `supervisor.late_response_discarded` event) instead of being forwarded —
|
|
634
|
+
* the synthesized response was terminal, and exactly one response per id
|
|
635
|
+
* must reach the client. A tombstone is cleared when its id is re-forwarded
|
|
636
|
+
* to a worker (forwardRequest), because a client may legally retry with the
|
|
637
|
+
* same id; finality is per request INSTANCE, not per id forever.
|
|
638
|
+
*
|
|
639
|
+
* `mode` is retained deliberately even though discard writes nothing today:
|
|
640
|
+
* a tombstoned id keeps its originating framing mode until discard, so any
|
|
641
|
+
* future terminal write for that id — should the discard policy ever gain
|
|
642
|
+
* one — answers in the originating framing.
|
|
643
|
+
*
|
|
644
|
+
* Retention bound: entries with generation ≤ G are dropped when the
|
|
645
|
+
* generation-G child emits "close" (process ended AND its stdio streams
|
|
646
|
+
* closed — Node's guarantee for that event). This is safe: worker frames
|
|
647
|
+
* only enter handleWorkerMessage from the CURRENT child, the supervisor
|
|
648
|
+
* only forwards ids with live pending entries, and forwarding clears the
|
|
649
|
+
* id's tombstone — so once every generation that could have seen a
|
|
650
|
+
* tombstoned id is gone with streams closed, no current child can ever
|
|
651
|
+
* deliver a frame for it. Also cleared wholesale on shutdown.
|
|
652
|
+
*/
|
|
653
|
+
syntheticTombstones = new Map();
|
|
510
654
|
child;
|
|
511
655
|
childReady = false;
|
|
656
|
+
/**
|
|
657
|
+
* Monotonic timestamp of the current generation's adoption, or undefined
|
|
658
|
+
* while no generation is adopted. Read once at exit to tell a generation
|
|
659
|
+
* that served from one that stood down the moment it came up.
|
|
660
|
+
*/
|
|
661
|
+
childReadyAt;
|
|
662
|
+
/**
|
|
663
|
+
* Consecutive adopted generations that stood down CLEANLY before they had
|
|
664
|
+
* been ready for MIN_HEALTHY_WORKER_READY_MS. Any other outcome — a crash, a
|
|
665
|
+
* signal, a generation that served — clears it.
|
|
666
|
+
*/
|
|
667
|
+
consecutiveImmediateStandDowns = 0;
|
|
512
668
|
shuttingDown = false;
|
|
513
669
|
restartTimer;
|
|
670
|
+
cleanupRetryTimer;
|
|
671
|
+
cleanupRetryIndex = 0;
|
|
672
|
+
cleanupRetryAttempts = new Map();
|
|
673
|
+
cleanupRetriesInFlight = new Map();
|
|
514
674
|
startupWatchdog;
|
|
515
675
|
validateBarrierKey;
|
|
516
676
|
runningValidateKey;
|
|
@@ -519,12 +679,58 @@ export class StdioSupervisor {
|
|
|
519
679
|
currentRetryReservation;
|
|
520
680
|
retryPaused = false;
|
|
521
681
|
workerStderrBuffer = "";
|
|
522
|
-
|
|
682
|
+
/**
|
|
683
|
+
* Framing mode of the most recently detected inbound client frame. NOT used
|
|
684
|
+
* for request-correlated writes (those use the originating request's
|
|
685
|
+
* captured mode); this is only the documented fallback for client-bound
|
|
686
|
+
* writes that correlate to no request id (worker-originated notifications
|
|
687
|
+
* and server->client requests forwarded at handleWorkerMessage, and
|
|
688
|
+
* untracked-id responses with no finality tombstone — tombstoned ids are
|
|
689
|
+
* discarded, never forwarded).
|
|
690
|
+
*/
|
|
691
|
+
lastInboundClientMode = DEFAULT_CLIENT_MODE;
|
|
692
|
+
/**
|
|
693
|
+
* Per-frame framing mode of every inbound client message, keyed by message
|
|
694
|
+
* object identity. Lets admission (createPendingRequest), queued-message
|
|
695
|
+
* flushes, and initialize replay recover the ORIGINATING frame's mode long
|
|
696
|
+
* after the frame was parsed.
|
|
697
|
+
*/
|
|
698
|
+
inboundFrameModes = new WeakMap();
|
|
523
699
|
initializeRequest;
|
|
524
700
|
initializedNotification;
|
|
701
|
+
/**
|
|
702
|
+
* Number of queued requests that arrived before an initial initialize while
|
|
703
|
+
* the worker was unavailable. Only era-neutral modern discovers can occupy
|
|
704
|
+
* this prefix; readiness forwards it before initialize, then keeps the queue
|
|
705
|
+
* suffix gated until the initialization response.
|
|
706
|
+
*/
|
|
707
|
+
initializePredecessorCount = 0;
|
|
525
708
|
clientInitialized = false;
|
|
526
709
|
replayingInitialization = false;
|
|
527
710
|
initializeSentToWorker = false;
|
|
711
|
+
/**
|
|
712
|
+
* Process-lifetime era state. The supervisor is the SOLE era gatekeeper:
|
|
713
|
+
* classification happens at admission (handleClientMessage), in stdin
|
|
714
|
+
* order. The first valid NON-discover era signal locks the era (initialize
|
|
715
|
+
* → legacy; shallow-valid modern `_meta` request → modern); the lock is
|
|
716
|
+
* one-way and survives worker restarts.
|
|
717
|
+
*/
|
|
718
|
+
era = "unselected";
|
|
719
|
+
/**
|
|
720
|
+
* Whether `start()` attached this instance to the real process stdio. Only
|
|
721
|
+
* then may a teardown touch process-wide state (the framing-fatal exit
|
|
722
|
+
* code); white-box suites construct supervisors without starting them and
|
|
723
|
+
* must never have their own runner's exit status rewritten.
|
|
724
|
+
*/
|
|
725
|
+
ownsProcessStdio = false;
|
|
726
|
+
/**
|
|
727
|
+
* Whether a fatal fault has already armed the explicit process exit. A
|
|
728
|
+
* second fault must not stack another watchdog or cut the first teardown
|
|
729
|
+
* short.
|
|
730
|
+
*/
|
|
731
|
+
fatalExitScheduled = false;
|
|
732
|
+
/** Memoized teardown; see shutdown(). */
|
|
733
|
+
shutdownRun;
|
|
528
734
|
constructor(options) {
|
|
529
735
|
this.entryFile = options.entryFile;
|
|
530
736
|
this.validateProjectTimeoutMs = options.validateProjectTimeoutMs ?? loadValidateProjectTimeoutMs();
|
|
@@ -532,6 +738,8 @@ export class StdioSupervisor {
|
|
|
532
738
|
this.clientWriter = options.clientWriter;
|
|
533
739
|
this.treeTokenRetrier = options.treeTokenRetrier;
|
|
534
740
|
this.treeCleanupTimeoutMs = options.treeCleanupTimeoutMs ?? DEFAULT_TREE_CLEANUP_TIMEOUT_MS;
|
|
741
|
+
this.cleanupTokenRetryBaseMs = Math.max(1, Math.floor(options.cleanupTokenRetryBaseMs ?? DEFAULT_CLEANUP_TOKEN_RETRY_BASE_MS));
|
|
742
|
+
this.cleanupTokenRetryCapMs = Math.max(this.cleanupTokenRetryBaseMs, Math.floor(options.cleanupTokenRetryCapMs ?? DEFAULT_CLEANUP_TOKEN_RETRY_CAP_MS));
|
|
535
743
|
this.eventWriter = options.eventWriter ?? log;
|
|
536
744
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
537
745
|
this.timerScheduler = options.timerScheduler ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
@@ -547,6 +755,7 @@ export class StdioSupervisor {
|
|
|
547
755
|
this.treeTerminator = options.treeTerminator;
|
|
548
756
|
}
|
|
549
757
|
async start() {
|
|
758
|
+
this.ownsProcessStdio = true;
|
|
550
759
|
process.stdin.on("data", this.handleClientData);
|
|
551
760
|
process.stdin.on("error", this.handleClientError);
|
|
552
761
|
process.stdin.on("end", this.handleClientClosed);
|
|
@@ -554,19 +763,51 @@ export class StdioSupervisor {
|
|
|
554
763
|
process.stdin.resume();
|
|
555
764
|
process.on("SIGINT", this.handleTerminateSignal);
|
|
556
765
|
process.on("SIGTERM", this.handleTerminateSignal);
|
|
766
|
+
// SIGHUP is what a terminating launcher or a vanishing session sends.
|
|
767
|
+
// Left to the OS default it kills this process where it stands, and the
|
|
768
|
+
// detached worker process group survives with nobody left to reap it.
|
|
769
|
+
process.on("SIGHUP", this.handleTerminateSignal);
|
|
770
|
+
// The supervisor OWNS this process's fatal handlers: it is the only party
|
|
771
|
+
// that can reach shutdown() and terminate the worker's process group.
|
|
772
|
+
// Node's default handler prints a stack and leaves the worker orphaned.
|
|
773
|
+
process.on("uncaughtException", this.handleFatalError);
|
|
774
|
+
process.on("unhandledRejection", this.handleFatalError);
|
|
557
775
|
this.spawnWorker();
|
|
558
776
|
}
|
|
559
777
|
handleClientData = (chunk) => {
|
|
560
778
|
this.clientReader.processChunk(chunk, {
|
|
561
779
|
onFrame: ({ message, mode }) => {
|
|
562
|
-
this.
|
|
780
|
+
this.inboundFrameModes.set(message, mode);
|
|
781
|
+
this.lastInboundClientMode = mode;
|
|
563
782
|
this.handleClientMessage(message);
|
|
564
783
|
},
|
|
565
784
|
onError: (error) => {
|
|
785
|
+
if (isJsonRpcFramingFatalError(error)) {
|
|
786
|
+
// The client stream can no longer be framed. Every later byte would
|
|
787
|
+
// be guesswork, so the session ends with this diagnostic rather than
|
|
788
|
+
// leaving a live process that silently answers nothing (the wedge
|
|
789
|
+
// this repair removes).
|
|
790
|
+
this.eventWriter("error", "supervisor.client_framing_fatal", {
|
|
791
|
+
message: error.message
|
|
792
|
+
});
|
|
793
|
+
if (this.ownsProcessStdio) {
|
|
794
|
+
// A client protocol violation ended the session — distinguishable
|
|
795
|
+
// by a launcher from the code 0 of an ordinary stdin close. Set the
|
|
796
|
+
// code rather than calling process.exit(), so the shutdown finishes
|
|
797
|
+
// and the diagnostic is flushed.
|
|
798
|
+
process.exitCode = 1;
|
|
799
|
+
}
|
|
800
|
+
void this.shutdown();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
566
803
|
log("warn", "supervisor.client_parse_error", { message: error.message });
|
|
567
804
|
}
|
|
568
805
|
});
|
|
569
806
|
};
|
|
807
|
+
/** The captured per-frame mode of an inbound client message, if known. */
|
|
808
|
+
modeForMessage(message) {
|
|
809
|
+
return message === undefined ? undefined : this.inboundFrameModes.get(message);
|
|
810
|
+
}
|
|
570
811
|
handleClientError = (error) => {
|
|
571
812
|
log("warn", "supervisor.client_stream_error", { message: error.message });
|
|
572
813
|
};
|
|
@@ -576,6 +817,44 @@ export class StdioSupervisor {
|
|
|
576
817
|
handleTerminateSignal = () => {
|
|
577
818
|
void this.shutdown();
|
|
578
819
|
};
|
|
820
|
+
/**
|
|
821
|
+
* Terminal fault in the supervisor process. Reported through the injected
|
|
822
|
+
* event writer, then routed into the ordinary shutdown so the detached
|
|
823
|
+
* worker group is reaped rather than orphaned by a crash.
|
|
824
|
+
*/
|
|
825
|
+
handleFatalError = (reason) => {
|
|
826
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
827
|
+
this.eventWriter("error", "supervisor.fatal", {
|
|
828
|
+
message: error.message,
|
|
829
|
+
stack: error.stack
|
|
830
|
+
});
|
|
831
|
+
// Always the SAME teardown promise, so a second fault arriving mid-shutdown
|
|
832
|
+
// waits for the worker group to be reaped instead of racing it to the exit.
|
|
833
|
+
const teardown = this.shutdown();
|
|
834
|
+
if (!this.ownsProcessStdio) {
|
|
835
|
+
// Embedded or white-box supervisor: the host owns this process's fate.
|
|
836
|
+
void teardown.catch(() => undefined);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
process.exitCode = 1;
|
|
840
|
+
if (this.fatalExitScheduled)
|
|
841
|
+
return;
|
|
842
|
+
this.fatalExitScheduled = true;
|
|
843
|
+
// Registering fatal handlers suppressed node's default abort, so the exit
|
|
844
|
+
// is no longer implied by the crash — it has to be performed. Cleanup
|
|
845
|
+
// first, bounded, then leave with the non-zero status regardless of what
|
|
846
|
+
// is still holding the event loop.
|
|
847
|
+
const watchdog = setTimeout(() => {
|
|
848
|
+
this.eventWriter("error", "supervisor.fatal_exit_forced", {
|
|
849
|
+
timeoutMs: FATAL_SHUTDOWN_WATCHDOG_MS
|
|
850
|
+
});
|
|
851
|
+
process.exit(1);
|
|
852
|
+
}, FATAL_SHUTDOWN_WATCHDOG_MS);
|
|
853
|
+
void teardown.catch(() => undefined).then(() => {
|
|
854
|
+
clearTimeout(watchdog);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
});
|
|
857
|
+
};
|
|
579
858
|
handleClientMessage(message) {
|
|
580
859
|
debugSupervisor("client_message", {
|
|
581
860
|
hasMethod: "method" in message,
|
|
@@ -583,45 +862,62 @@ export class StdioSupervisor {
|
|
|
583
862
|
id: "id" in message ? message.id : undefined,
|
|
584
863
|
childReady: this.childReady
|
|
585
864
|
});
|
|
586
|
-
if (isRequest(message) && message.method === "initialize") {
|
|
587
|
-
this.initializeRequest = message;
|
|
588
|
-
this.clientInitialized = false;
|
|
589
|
-
}
|
|
590
|
-
else if (isNotification(message) && message.method === "notifications/initialized") {
|
|
591
|
-
this.initializedNotification = message;
|
|
592
|
-
}
|
|
593
865
|
if (isNotification(message)) {
|
|
594
|
-
|
|
595
|
-
this.handleCancellation(message);
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
if (!this.childReady) {
|
|
599
|
-
if (!shouldRetainUnavailableNotification(message.method)) {
|
|
600
|
-
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
601
|
-
method: message.method,
|
|
602
|
-
reason: this.liveCapOccupancy() >= 2 ? "live-cap-blocked" : "worker-unavailable"
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
if (this.child && !this.child.stdin.destroyed) {
|
|
608
|
-
this.writeToWorker(this.child, message);
|
|
609
|
-
}
|
|
866
|
+
this.handleClientNotification(message);
|
|
610
867
|
return;
|
|
611
868
|
}
|
|
612
869
|
if (!isRequest(message)) {
|
|
613
870
|
return;
|
|
614
871
|
}
|
|
615
872
|
if (message.method === "initialize") {
|
|
873
|
+
// initialize is the legacy era signal regardless of any _meta envelope
|
|
874
|
+
// it carries: the envelope is ignored for era classification here.
|
|
875
|
+
if (this.era === "modern") {
|
|
876
|
+
// One-way era lock: a rejected initialize is NEVER captured into the
|
|
877
|
+
// replay cache.
|
|
878
|
+
this.writeToClient(buildEraConflictRejection(message.id, "modern"), this.modeForMessage(message));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (!isCompleteInitializeRequest(message)) {
|
|
882
|
+
// The legacy lock is ONE-WAY and lasts the process lifetime, so it may
|
|
883
|
+
// only be committed on a frame that is actually a valid era opening.
|
|
884
|
+
// Generic JSON-RPC parsing is not that check: `params: {}` reaches
|
|
885
|
+
// here as a well-formed request and used to burn the lock, after which
|
|
886
|
+
// the worker rejected the handshake, the supervisor replaced that with
|
|
887
|
+
// a -32603 telling the client to RETRY, and the retry — or any modern
|
|
888
|
+
// request — could never succeed in this process again. Rejecting here
|
|
889
|
+
// leaves the era UNSELECTED, which makes that advice true.
|
|
890
|
+
this.eventWriter("warn", "supervisor.invalid_initialize_rejected", {
|
|
891
|
+
id: message.id
|
|
892
|
+
});
|
|
893
|
+
this.writeToClient(buildInvalidInitializeRejection(message.id), this.modeForMessage(message));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
this.era = "legacy";
|
|
897
|
+
// The SDK's opening classifier treats an initialize that carries a
|
|
898
|
+
// valid modern era claim as MODERN, diverging from this admission rule
|
|
899
|
+
// ("initialize is the legacy era signal; the envelope is ignored") and
|
|
900
|
+
// failing the handshake on the worker. Strip the era-claim keys before
|
|
901
|
+
// the frame is cached, queued, or forwarded (in place: the framing-mode
|
|
902
|
+
// registry is keyed by this exact frame object).
|
|
903
|
+
stripModernEraClaimInPlace(message.params);
|
|
616
904
|
if (!this.child && this.liveCapOccupancy() >= 2) {
|
|
905
|
+
// Capture-ordering fix: the cap-blocked rejection happens BEFORE
|
|
906
|
+
// capture, so a rejected initialize can never enter (or corrupt) the
|
|
907
|
+
// replay cache. clearInitialInitializationState() still discards any
|
|
908
|
+
// NOT-yet-completed earlier handshake, exactly as before.
|
|
617
909
|
this.clearInitialInitializationState();
|
|
618
|
-
this.
|
|
910
|
+
this.writeSyntheticReply({ id: message.id, era: this.era, mode: this.modeForMessage(message) }, buildLegacyJsonRpcError(message.id));
|
|
619
911
|
return;
|
|
620
912
|
}
|
|
913
|
+
this.initializeRequest = message;
|
|
914
|
+
this.clientInitialized = false;
|
|
621
915
|
if (this.childReady) {
|
|
916
|
+
this.initializePredecessorCount = 0;
|
|
622
917
|
this.forwardRequest(message, this.createPendingRequest(message));
|
|
623
918
|
}
|
|
624
919
|
else {
|
|
920
|
+
this.initializePredecessorCount = this.queuedRequests.length;
|
|
625
921
|
const existing = this.queuedNotifications.findIndex((entry) => isRequest(entry) && entry.method === "initialize");
|
|
626
922
|
if (existing >= 0)
|
|
627
923
|
this.queuedNotifications.splice(existing, 1);
|
|
@@ -629,19 +925,78 @@ export class StdioSupervisor {
|
|
|
629
925
|
}
|
|
630
926
|
return;
|
|
631
927
|
}
|
|
632
|
-
const
|
|
928
|
+
const eraSignal = this.admitEraRequest(message);
|
|
929
|
+
if (!eraSignal) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (this.era !== "legacy" && MODERN_UNSUPPORTED_METHODS.has(message.method)) {
|
|
933
|
+
// Admission-time method rejection, AFTER the envelope check and the era
|
|
934
|
+
// lock admitEraRequest already applied, so the documented ordering holds:
|
|
935
|
+
// a claim-less listen in a non-legacy state fails -32602 first, a
|
|
936
|
+
// modern-signal listen still locks modern, and only then is the method
|
|
937
|
+
// itself rejected. Rejected pre-queue: no pendingRequests entry is
|
|
938
|
+
// created, so an unanswerable request can never occupy a dispatch
|
|
939
|
+
// barrier or a queue slot.
|
|
940
|
+
this.eventWriter("info", "supervisor.unsupported_method_rejected", {
|
|
941
|
+
id: message.id,
|
|
942
|
+
method: message.method,
|
|
943
|
+
era: this.era
|
|
944
|
+
});
|
|
945
|
+
this.writeToClient(buildMethodNotFoundRejection(message.id), this.modeForMessage(message));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const pending = this.createPendingRequest(message, eraSignal);
|
|
633
949
|
if (!this.child && this.liveCapOccupancy() >= 2) {
|
|
634
950
|
const { reply } = buildWorkerRestartReply(pending, { code: null, signal: null }, this.monotonicNow(), [], { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
|
|
635
|
-
this.
|
|
951
|
+
this.writeSyntheticReply(pending, reply);
|
|
636
952
|
return;
|
|
637
953
|
}
|
|
638
954
|
const isValidate = pending.toolName === "validate-project";
|
|
639
955
|
const dispatchImmediately = this.canDispatchImmediately(pending);
|
|
640
956
|
if (!dispatchImmediately && this.queuedRequests.length >= MAX_SUPERVISOR_QUEUE) {
|
|
641
|
-
this.
|
|
957
|
+
this.writeSyntheticReply(pending, buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
|
|
642
958
|
this.drainQueue();
|
|
643
959
|
return;
|
|
644
960
|
}
|
|
961
|
+
if (this.era === "legacy" &&
|
|
962
|
+
message.method === "tools/call" &&
|
|
963
|
+
isToolArgumentsRecord(message.params) &&
|
|
964
|
+
Object.prototype.hasOwnProperty.call(message.params, "arguments")) {
|
|
965
|
+
const argumentsValue = message.params.arguments;
|
|
966
|
+
if (argumentsValue !== undefined && !isToolArgumentsRecord(argumentsValue)) {
|
|
967
|
+
this.writeSyntheticReply(pending, buildLegacyMalformedToolArgumentsReply(pending.id, argumentsValue));
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (this.era === "legacy" &&
|
|
972
|
+
message.method === "tools/call" &&
|
|
973
|
+
typeof pending.toolName === "string" &&
|
|
974
|
+
registeredToolCount() > 0 &&
|
|
975
|
+
getToolSchema(pending.toolName) === undefined) {
|
|
976
|
+
// LEGACY-era registry miss (typo or flag-disabled tool): answer the
|
|
977
|
+
// frozen premigration isError envelope. The registry is authoritative
|
|
978
|
+
// in production (src/cli.ts imports ./index.js before the supervisor
|
|
979
|
+
// starts, under the same env flags the worker inherits); the
|
|
980
|
+
// populated-registry gate keeps white-box suites that construct the
|
|
981
|
+
// supervisor without the app import on today's forwarding behavior.
|
|
982
|
+
// Semantics: DEGRADED states answer first — the cap-blocked restart
|
|
983
|
+
// and queue-overflow branches above take precedence exactly as before
|
|
984
|
+
// the intercept existed — while in the normal state the miss is
|
|
985
|
+
// answered immediately, pre-queue: it never enters pendingRequests or
|
|
986
|
+
// queuedRequests, so writeSyntheticReply's forwarded-only entitlement
|
|
987
|
+
// records no finality tombstone, and — unlike v1, which forwarded the
|
|
988
|
+
// call — it consumes no queue slot and skips the worker round-trip
|
|
989
|
+
// (identical per-request reply bytes, different queue occupancy and
|
|
990
|
+
// latency). Modern-era misses pass through to the worker's raw -32602
|
|
991
|
+
// (sanctioned modern contract); the unselected state never reaches
|
|
992
|
+
// here (rule-5 rejection in admitEraRequest).
|
|
993
|
+
this.eventWriter("info", "supervisor.unknown_tool_intercepted", {
|
|
994
|
+
id: pending.id,
|
|
995
|
+
toolName: pending.toolName
|
|
996
|
+
});
|
|
997
|
+
this.writeSyntheticReply(pending, buildUnknownToolNotFoundReply(pending.id, pending.toolName));
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
645
1000
|
if (isValidate) {
|
|
646
1001
|
pending.timeoutPhase = "queue";
|
|
647
1002
|
const elapsedAtAdmission = Math.max(0, this.monotonicNow() - pending.startedAt);
|
|
@@ -657,12 +1012,219 @@ export class StdioSupervisor {
|
|
|
657
1012
|
}
|
|
658
1013
|
this.queuedRequests.push({ message, pending });
|
|
659
1014
|
}
|
|
660
|
-
|
|
1015
|
+
/**
|
|
1016
|
+
* Era-aware notification dispatch. notifications/cancelled stays
|
|
1017
|
+
* supervisor-side in ALL eras/states; notifications/initialized is captured
|
|
1018
|
+
* and forwarded only within a legacy flow; every other notification is
|
|
1019
|
+
* gated by the era rules before reaching the original forwarding path.
|
|
1020
|
+
* Dropped variants emit NO response and never affect subsequent traffic.
|
|
1021
|
+
*/
|
|
1022
|
+
handleClientNotification(message) {
|
|
1023
|
+
if (message.method === "notifications/cancelled") {
|
|
1024
|
+
this.handleCancellation(message);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (message.method === "notifications/initialized") {
|
|
1028
|
+
if (this.era !== "legacy") {
|
|
1029
|
+
// A stray initialized outside a legacy flow is dropped and NEVER
|
|
1030
|
+
// captured: forwarding (or later replaying) it would pin a worker
|
|
1031
|
+
// connection legacy.
|
|
1032
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1033
|
+
method: message.method,
|
|
1034
|
+
reason: this.era === "modern" ? "era-conflict" : "era-unselected"
|
|
1035
|
+
});
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (this.initializeRequest === undefined) {
|
|
1039
|
+
// Legacy era but NO handshake in progress (e.g. after a cap-rejected
|
|
1040
|
+
// initialize): capturing here could later replay the stray frame
|
|
1041
|
+
// around an uninitialized worker. Drop, never capture.
|
|
1042
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1043
|
+
method: message.method,
|
|
1044
|
+
reason: "no-active-handshake"
|
|
1045
|
+
});
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
this.initializedNotification = message;
|
|
1049
|
+
}
|
|
1050
|
+
else if (this.era === "unselected") {
|
|
1051
|
+
if (this.childReady) {
|
|
1052
|
+
// Consumed at the supervisor: forwarding any notification before an
|
|
1053
|
+
// era is selected would pin the worker connection legacy. The
|
|
1054
|
+
// worker-unavailable branch below keeps its original drop reasons.
|
|
1055
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1056
|
+
method: message.method,
|
|
1057
|
+
reason: "era-unselected"
|
|
1058
|
+
});
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
const signal = classifyEraSignal(message.params);
|
|
1064
|
+
if (this.era === "legacy" && signal.classification === "modern-signal") {
|
|
1065
|
+
// Only shallow-VALID modern signals conflict with the legacy lock;
|
|
1066
|
+
// claim-less and claim-shaped-invalid notifications forward as today
|
|
1067
|
+
// (the legacy era stays maximally permissive).
|
|
1068
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1069
|
+
method: message.method,
|
|
1070
|
+
reason: "era-conflict"
|
|
1071
|
+
});
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (this.era === "modern" && signal.classification !== "modern-signal") {
|
|
1075
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1076
|
+
method: message.method,
|
|
1077
|
+
reason: "missing-meta"
|
|
1078
|
+
});
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (!this.childReady) {
|
|
1083
|
+
if (!shouldRetainUnavailableNotification(message.method)) {
|
|
1084
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1085
|
+
method: message.method,
|
|
1086
|
+
reason: this.liveCapOccupancy() >= 2 ? "live-cap-blocked" : "worker-unavailable"
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (this.child && !this.child.stdin.destroyed) {
|
|
1092
|
+
this.writeToWorker(this.child, message);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Era rules for non-initialize requests, applied at admission in stdin
|
|
1097
|
+
* order. Returns undefined when the request was terminally rejected here (a
|
|
1098
|
+
* supervisor-produced response was written; nothing may be forwarded or
|
|
1099
|
+
* queued). On admission it returns the admission-time era signal, which the
|
|
1100
|
+
* caller hands to createPendingRequest so snapshot capture uses the same
|
|
1101
|
+
* result as the gating decision (extractModernRequestContext re-checks the
|
|
1102
|
+
* same immutable params graph defensively — a pure re-evaluation, not a
|
|
1103
|
+
* second authority); the caller then proceeds through the original era-less
|
|
1104
|
+
* admission path unchanged.
|
|
1105
|
+
*/
|
|
1106
|
+
admitEraRequest(message) {
|
|
1107
|
+
const signal = classifyEraSignal(message.params);
|
|
1108
|
+
const mode = this.modeForMessage(message);
|
|
1109
|
+
const id = message.id;
|
|
1110
|
+
if (message.method === "server/discover") {
|
|
1111
|
+
// server/discover is ERA-NEUTRAL: it forwards under the legacy lock
|
|
1112
|
+
// (the legacy-pinned worker answers -32601) and a modern-signal
|
|
1113
|
+
// discover forwards WITHOUT locking; only signal-less discovers in
|
|
1114
|
+
// non-legacy states are rejected.
|
|
1115
|
+
if (this.era === "legacy") {
|
|
1116
|
+
return signal;
|
|
1117
|
+
}
|
|
1118
|
+
if (signal.classification === "modern-signal") {
|
|
1119
|
+
if (this.rejectUnsupportedProtocolVersion(message, signal, mode)) {
|
|
1120
|
+
return undefined;
|
|
1121
|
+
}
|
|
1122
|
+
return signal;
|
|
1123
|
+
}
|
|
1124
|
+
this.writeToClient(buildMissingMetaRejection(id, signal, this.era === "modern" ? "modern" : "unselected"), mode);
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
if (signal.classification === "modern-signal") {
|
|
1128
|
+
if (this.era === "legacy") {
|
|
1129
|
+
this.writeToClient(buildEraConflictRejection(id, "legacy"), mode);
|
|
1130
|
+
return undefined;
|
|
1131
|
+
}
|
|
1132
|
+
if (this.era === "unselected") {
|
|
1133
|
+
this.lockModernEra();
|
|
1134
|
+
}
|
|
1135
|
+
if (this.rejectUnsupportedProtocolVersion(message, signal, mode)) {
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
return signal;
|
|
1139
|
+
}
|
|
1140
|
+
if (this.era === "legacy") {
|
|
1141
|
+
// Legacy-locked behavior is otherwise UNCHANGED: claim-less AND
|
|
1142
|
+
// claim-shaped-invalid traffic forwards exactly as before the era gate.
|
|
1143
|
+
return signal;
|
|
1144
|
+
}
|
|
1145
|
+
this.writeToClient(buildMissingMetaRejection(id, signal, this.era === "modern" ? "modern" : "unselected"), mode);
|
|
1146
|
+
return undefined;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Deep validation of a shallow-valid modern claim's protocolVersion VALUE,
|
|
1150
|
+
* applied to EVERY modern request at admission. Returns whether the request
|
|
1151
|
+
* was terminally answered here.
|
|
1152
|
+
*
|
|
1153
|
+
* The version check used to be delegated entirely to the worker, and the SDK
|
|
1154
|
+
* only performs it while the connection is still OPENING: once an instance
|
|
1155
|
+
* is pinned, `processMessage` delivers straight to it without re-reading the
|
|
1156
|
+
* envelope. So the contract ("any unsupported version string answers -32022
|
|
1157
|
+
* with data.supported/data.requested") held for exactly one request per
|
|
1158
|
+
* process — and whether it held at all depended on which method the client
|
|
1159
|
+
* happened to send first, since the era-neutral `server/discover` does not
|
|
1160
|
+
* pin. Every later request with `"1999-12-31"` was SERVED, tool handlers
|
|
1161
|
+
* included.
|
|
1162
|
+
*
|
|
1163
|
+
* Ordering is deliberate and matches the SDK's own listen path: the envelope
|
|
1164
|
+
* shape is checked first (era gating above), the era lock is committed
|
|
1165
|
+
* BEFORE this check — an unsupported version still locks modern, exactly as
|
|
1166
|
+
* documented — and only then is the value judged, ahead of any method-level
|
|
1167
|
+
* rejection. The reply is built from the SDK's own
|
|
1168
|
+
* UnsupportedProtocolVersionError, so it stays byte-identical to the answer
|
|
1169
|
+
* the worker gives on the still-opening path.
|
|
1170
|
+
*/
|
|
1171
|
+
rejectUnsupportedProtocolVersion(message, signal, mode) {
|
|
1172
|
+
if (signal.classification !== "modern-signal") {
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
1175
|
+
const meta = message.params._meta;
|
|
1176
|
+
const requested = meta[PROTOCOL_VERSION_META_KEY];
|
|
1177
|
+
if (requested === MODERN_PROTOCOL_VERSION) {
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
this.eventWriter("warn", "supervisor.unsupported_protocol_version", {
|
|
1181
|
+
id: message.id,
|
|
1182
|
+
method: message.method,
|
|
1183
|
+
requested
|
|
1184
|
+
});
|
|
1185
|
+
this.writeToClient(buildUnsupportedProtocolVersionRejection(message.id, requested), mode);
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* One-way modern lock. Purges the cached legacy lifecycle state so no
|
|
1190
|
+
* initialize/notifications/initialized can ever reach a worker in a
|
|
1191
|
+
* modern-locked process — across ALL later worker generations.
|
|
1192
|
+
*/
|
|
1193
|
+
lockModernEra() {
|
|
1194
|
+
this.era = "modern";
|
|
1195
|
+
this.initializeRequest = undefined;
|
|
1196
|
+
this.initializedNotification = undefined;
|
|
1197
|
+
this.initializePredecessorCount = 0;
|
|
1198
|
+
this.clientInitialized = false;
|
|
1199
|
+
this.replayingInitialization = false;
|
|
1200
|
+
this.initializeSentToWorker = false;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* `eraSignal` is the ADMISSION-time classification handed back by
|
|
1204
|
+
* admitEraRequest — the initialize/replay/flush call sites omit it (an
|
|
1205
|
+
* initialize snapshot is always era "legacy" with no modern context, even
|
|
1206
|
+
* when the initialize carries a _meta envelope). Context is captured only
|
|
1207
|
+
* for shallow-valid modern signals; this.era already reflects any lock
|
|
1208
|
+
* admitEraRequest applied, so it IS the era at admission on every path.
|
|
1209
|
+
*/
|
|
1210
|
+
createPendingRequest(message, eraSignal) {
|
|
661
1211
|
const pending = {
|
|
662
1212
|
id: message.id,
|
|
663
1213
|
method: message.method,
|
|
664
|
-
startedAt: this.monotonicNow()
|
|
1214
|
+
startedAt: this.monotonicNow(),
|
|
1215
|
+
mode: this.modeForMessage(message),
|
|
1216
|
+
era: this.era
|
|
665
1217
|
};
|
|
1218
|
+
if (eraSignal?.classification === "modern-signal") {
|
|
1219
|
+
const context = extractModernRequestContext(message.params);
|
|
1220
|
+
if (context) {
|
|
1221
|
+
pending.protocolVersion = context.protocolVersion;
|
|
1222
|
+
pending.clientCapabilities = context.clientCapabilities;
|
|
1223
|
+
if ("clientInfo" in context) {
|
|
1224
|
+
pending.clientInfo = context.clientInfo;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
666
1228
|
if (message.method === "tools/call") {
|
|
667
1229
|
const params = (message.params ?? {});
|
|
668
1230
|
if (typeof params.name === "string")
|
|
@@ -694,11 +1256,15 @@ export class StdioSupervisor {
|
|
|
694
1256
|
else {
|
|
695
1257
|
if (pending.deadlineTimer)
|
|
696
1258
|
this.timerClearer(pending.deadlineTimer);
|
|
697
|
-
this.
|
|
1259
|
+
this.writeSyntheticReply(pending, buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
|
|
698
1260
|
}
|
|
699
1261
|
this.scheduleRestart();
|
|
700
1262
|
return;
|
|
701
1263
|
}
|
|
1264
|
+
// The id goes live on a worker: a finality tombstone from an EARLIER
|
|
1265
|
+
// request instance that reused this id no longer applies (a client may
|
|
1266
|
+
// legally retry with the same id after a synthetic terminal reply).
|
|
1267
|
+
this.syntheticTombstones.delete(requestKey(pending.id));
|
|
702
1268
|
this.pendingRequests.set(requestKey(pending.id), pending);
|
|
703
1269
|
if (pending.toolName === "validate-project") {
|
|
704
1270
|
pending.timeoutPhase = "running";
|
|
@@ -725,6 +1291,9 @@ export class StdioSupervisor {
|
|
|
725
1291
|
const key = requestKey(targetId);
|
|
726
1292
|
const queuedIndex = this.queuedRequests.findIndex((entry) => requestKey(entry.pending.id) === key);
|
|
727
1293
|
if (queuedIndex >= 0) {
|
|
1294
|
+
if (queuedIndex < this.initializePredecessorCount) {
|
|
1295
|
+
this.initializePredecessorCount -= 1;
|
|
1296
|
+
}
|
|
728
1297
|
const [{ pending }] = this.queuedRequests.splice(queuedIndex, 1);
|
|
729
1298
|
if (pending.deadlineTimer)
|
|
730
1299
|
this.timerClearer(pending.deadlineTimer);
|
|
@@ -734,13 +1303,100 @@ export class StdioSupervisor {
|
|
|
734
1303
|
return;
|
|
735
1304
|
}
|
|
736
1305
|
const pending = this.pendingRequests.get(key);
|
|
737
|
-
|
|
738
|
-
|
|
1306
|
+
const released = pending !== undefined && this.releaseCancelledRequest(key, pending);
|
|
1307
|
+
if (this.shouldForwardCancellation(message, pending !== undefined)) {
|
|
1308
|
+
const child = this.child;
|
|
1309
|
+
if (child && !child.stdin.destroyed) {
|
|
1310
|
+
this.writeToWorker(child, message);
|
|
1311
|
+
}
|
|
739
1312
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1313
|
+
if (released) {
|
|
1314
|
+
// The released entry may have been the last occupant of a dispatch
|
|
1315
|
+
// barrier, so queued work can move immediately.
|
|
1316
|
+
this.drainQueue();
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Terminal release of a FORWARDED request the client cancelled.
|
|
1321
|
+
*
|
|
1322
|
+
* MCP cancellation semantics forbid a response for a cancelled id, so the
|
|
1323
|
+
* supervisor stops waiting on the worker the moment the cancellation is
|
|
1324
|
+
* admitted — and a worker is under no obligation to ever answer a cancelled
|
|
1325
|
+
* request. Keeping the entry in `pendingRequests` as a suppression marker
|
|
1326
|
+
* (the pre-repair behavior) therefore stranded EVERY dispatch barrier the
|
|
1327
|
+
* map gates — canDispatchImmediately's empty-map requirement, drainQueue's
|
|
1328
|
+
* validate release — for the life of the process whenever the worker never
|
|
1329
|
+
* answered, and let a client grow the map without bound by pairing fresh
|
|
1330
|
+
* ids with cancellations.
|
|
1331
|
+
*
|
|
1332
|
+
* Suppression is instead recorded as an ordinary response-finality
|
|
1333
|
+
* tombstone, which already carries exactly the needed semantics: a late
|
|
1334
|
+
* worker answer for the id is discarded with a logged event, and
|
|
1335
|
+
* re-forwarding the id (a legal retry) clears it. The result is one
|
|
1336
|
+
* mechanism for "this request instance is over" rather than two.
|
|
1337
|
+
*
|
|
1338
|
+
* Carve-out: an in-flight `initialize` is NOT released. Its pending entry is
|
|
1339
|
+
* owned by the legacy handshake lifecycle (replay correlation,
|
|
1340
|
+
* isInitializationResponse, the preserved-key rule in
|
|
1341
|
+
* failPendingRequestsOnWorkerExit), which has its own recovery paths
|
|
1342
|
+
* (startup watchdog, worker exit, clearInitialInitializationState) and never
|
|
1343
|
+
* consulted the cancellation marker anyway — so handshake behavior is
|
|
1344
|
+
* unchanged, byte for byte.
|
|
1345
|
+
*
|
|
1346
|
+
* Returns whether an entry was actually released.
|
|
1347
|
+
*/
|
|
1348
|
+
releaseCancelledRequest(key, pending) {
|
|
1349
|
+
if (pending.method === "initialize") {
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
if (pending.deadlineTimer)
|
|
1353
|
+
this.timerClearer(pending.deadlineTimer);
|
|
1354
|
+
pending.deadlineTimer = undefined;
|
|
1355
|
+
this.pendingRequests.delete(key);
|
|
1356
|
+
if (this.runningValidateKey === key)
|
|
1357
|
+
this.runningValidateKey = undefined;
|
|
1358
|
+
if (this.validateBarrierKey === key)
|
|
1359
|
+
this.validateBarrierKey = undefined;
|
|
1360
|
+
this.recordFinalityTombstone(key, pending.mode);
|
|
1361
|
+
this.eventWriter("info", "supervisor.request_cancelled", {
|
|
1362
|
+
id: pending.id,
|
|
1363
|
+
method: pending.method,
|
|
1364
|
+
toolName: pending.toolName
|
|
1365
|
+
});
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Era gating for a cancellation notification. Extracted from
|
|
1370
|
+
* handleCancellation so supervisor-side bookkeeping (releasing the cancelled
|
|
1371
|
+
* request) stays independent of whether the frame may reach the worker — a
|
|
1372
|
+
* DROPPED cancellation still cancels, exactly as documented.
|
|
1373
|
+
*/
|
|
1374
|
+
shouldForwardCancellation(message, hasPendingTarget) {
|
|
1375
|
+
const cancellationSignal = classifyEraSignal(message.params).classification;
|
|
1376
|
+
if (this.era === "unselected") {
|
|
1377
|
+
if (!hasPendingTarget || cancellationSignal === "claim-shaped-invalid") {
|
|
1378
|
+
// Unknown cancellations could be the worker connection's first frame;
|
|
1379
|
+
// claim-shaped-invalid notifications are not protocol-valid. Drop both.
|
|
1380
|
+
// A known active claim-less target is different: that pending request
|
|
1381
|
+
// proves this current worker already received a modern discover.
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
if (this.era === "modern") {
|
|
1386
|
+
const activeClaimlessCancellation = cancellationSignal === "claim-less" && hasPendingTarget;
|
|
1387
|
+
if (cancellationSignal !== "modern-signal" && !activeClaimlessCancellation) {
|
|
1388
|
+
// A claim-less cancellation is safe only while its target is pending:
|
|
1389
|
+
// that proves this current worker already received a modern request.
|
|
1390
|
+
// Unknown claim-less and claim-shaped-invalid cancellations remain
|
|
1391
|
+
// dropped so they can never legacy-pin a fresh worker generation.
|
|
1392
|
+
this.eventWriter("warn", "supervisor.notification_dropped", {
|
|
1393
|
+
method: message.method,
|
|
1394
|
+
reason: "missing-meta"
|
|
1395
|
+
});
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
743
1398
|
}
|
|
1399
|
+
return true;
|
|
744
1400
|
}
|
|
745
1401
|
handleValidateProjectDeadline(key) {
|
|
746
1402
|
const queuedIndex = this.queuedRequests.findIndex((entry) => requestKey(entry.pending.id) === key);
|
|
@@ -751,34 +1407,35 @@ export class StdioSupervisor {
|
|
|
751
1407
|
pending.deadlineTimer = undefined;
|
|
752
1408
|
if (this.validateBarrierKey === key)
|
|
753
1409
|
this.validateBarrierKey = undefined;
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
1410
|
+
// A cancelled queued entry was spliced out of queuedRequests (and its
|
|
1411
|
+
// timer cleared) at cancellation, so anything still here is live.
|
|
1412
|
+
this.writeSyntheticReply(pending, buildValidateProjectTimeoutReply({
|
|
1413
|
+
request: pending,
|
|
1414
|
+
phase: "queue",
|
|
1415
|
+
deadlineMs: this.validateProjectTimeoutMs,
|
|
1416
|
+
now: this.monotonicNow(),
|
|
1417
|
+
workerRestartInitiated: false
|
|
1418
|
+
}));
|
|
763
1419
|
this.drainQueue();
|
|
764
1420
|
return;
|
|
765
1421
|
}
|
|
766
1422
|
const pending = this.pendingRequests.get(key);
|
|
767
1423
|
if (!pending || pending.toolName !== "validate-project")
|
|
768
1424
|
return;
|
|
769
|
-
this.pendingRequests.delete(key);
|
|
770
1425
|
pending.deadlineTimer = undefined;
|
|
771
1426
|
this.runningValidateKey = undefined;
|
|
772
1427
|
this.validateBarrierKey = undefined;
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1428
|
+
// The entry stays in pendingRequests until writeSyntheticReply settles it:
|
|
1429
|
+
// the FORWARDED pending is what entitles the id to a finality tombstone.
|
|
1430
|
+
// A cancelled request never reaches here: releaseCancelledRequest already
|
|
1431
|
+
// settled it and cleared this timer.
|
|
1432
|
+
this.writeSyntheticReply(pending, buildValidateProjectTimeoutReply({
|
|
1433
|
+
request: pending,
|
|
1434
|
+
phase: "running",
|
|
1435
|
+
deadlineMs: this.validateProjectTimeoutMs,
|
|
1436
|
+
now: this.monotonicNow(),
|
|
1437
|
+
workerRestartInitiated: true
|
|
1438
|
+
}));
|
|
782
1439
|
this.recoverTimedOutWorker();
|
|
783
1440
|
}
|
|
784
1441
|
drainQueue() {
|
|
@@ -832,7 +1489,12 @@ export class StdioSupervisor {
|
|
|
832
1489
|
this.child = child;
|
|
833
1490
|
this.liveChildren.add(child);
|
|
834
1491
|
this.workerReaders.set(child, new JsonRpcFrameReader());
|
|
1492
|
+
const generation = ++this.workerGeneration;
|
|
1493
|
+
// "close" fires only once the process has ended AND its stdio streams are
|
|
1494
|
+
// closed — the tombstone retention bound (see syntheticTombstones).
|
|
1495
|
+
child.once("close", () => this.purgeTombstonesThroughGeneration(generation));
|
|
835
1496
|
this.childReady = false;
|
|
1497
|
+
this.childReadyAt = undefined;
|
|
836
1498
|
this.initializeSentToWorker = false;
|
|
837
1499
|
this.workerStderrBuffer = "";
|
|
838
1500
|
this.clearStartupWatchdog();
|
|
@@ -866,6 +1528,17 @@ export class StdioSupervisor {
|
|
|
866
1528
|
this.handleWorkerMessage(child, message);
|
|
867
1529
|
},
|
|
868
1530
|
onError: (error) => {
|
|
1531
|
+
if (isJsonRpcFramingFatalError(error)) {
|
|
1532
|
+
// The worker's own output stream desynchronized: its reader is dead,
|
|
1533
|
+
// so this generation can never be read again. Recover the way any
|
|
1534
|
+
// other unusable worker is recovered — terminate it and restart.
|
|
1535
|
+
this.eventWriter("error", "supervisor.worker_framing_fatal", {
|
|
1536
|
+
pid: child.pid,
|
|
1537
|
+
message: error.message
|
|
1538
|
+
});
|
|
1539
|
+
this.handleWorkerProcessError(child, error);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
869
1542
|
log("warn", "supervisor.worker_parse_error", { message: error.message });
|
|
870
1543
|
}
|
|
871
1544
|
});
|
|
@@ -904,6 +1577,9 @@ export class StdioSupervisor {
|
|
|
904
1577
|
this.handleStartupFailure(this.attemptToken, { code: null, signal: null });
|
|
905
1578
|
}
|
|
906
1579
|
else {
|
|
1580
|
+
// A stream/process fault is never a clean stand-down, so it breaks any
|
|
1581
|
+
// run of them.
|
|
1582
|
+
this.consecutiveImmediateStandDowns = 0;
|
|
907
1583
|
this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
|
|
908
1584
|
this.scheduleRestart(true);
|
|
909
1585
|
}
|
|
@@ -920,6 +1596,7 @@ export class StdioSupervisor {
|
|
|
920
1596
|
this.staleChildren.delete(child);
|
|
921
1597
|
if (cleanup.status === "pending" || cleanup.status === "unresolved") {
|
|
922
1598
|
this.unresolvedTreeTokens.add(cleanup.pid);
|
|
1599
|
+
this.scheduleCleanupTokenRetry();
|
|
923
1600
|
}
|
|
924
1601
|
else {
|
|
925
1602
|
this.cleanupStates.delete(child);
|
|
@@ -938,6 +1615,7 @@ export class StdioSupervisor {
|
|
|
938
1615
|
}
|
|
939
1616
|
const childPid = this.child?.pid;
|
|
940
1617
|
const wasReady = this.childReady;
|
|
1618
|
+
const readyAt = this.childReadyAt;
|
|
941
1619
|
this.detachCurrentChild();
|
|
942
1620
|
if (this.shuttingDown) {
|
|
943
1621
|
return;
|
|
@@ -952,9 +1630,40 @@ export class StdioSupervisor {
|
|
|
952
1630
|
this.handleStartupFailure(this.attemptToken, { code, signal });
|
|
953
1631
|
return;
|
|
954
1632
|
}
|
|
1633
|
+
this.accountForReadyGenerationExit(childPid, readyAt, code, signal);
|
|
955
1634
|
this.failPendingRequestsOnWorkerExit({ code, signal });
|
|
956
1635
|
this.scheduleRestart(true);
|
|
957
1636
|
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Decide whether the generation that just ended counts as a start that
|
|
1639
|
+
* succeeded.
|
|
1640
|
+
*
|
|
1641
|
+
* A worker stands down on stdin EOF, and it replays an EOF that arrived
|
|
1642
|
+
* while it was still starting. On a host that closes a spawned child's stdin
|
|
1643
|
+
* immediately, every generation therefore signals ready, is adopted — which
|
|
1644
|
+
* clears the restart backoff — and exits 0 in the same breath. Left at the
|
|
1645
|
+
* 100 ms floor that is an unbounded hot respawn loop, so a clean stand-down
|
|
1646
|
+
* inside the healthy window re-escalates the backoff the adoption cleared.
|
|
1647
|
+
*
|
|
1648
|
+
* Only a CLEAN exit qualifies: a non-zero code or a signal is a crash, which
|
|
1649
|
+
* may be transient and keeps its prompt replacement.
|
|
1650
|
+
*/
|
|
1651
|
+
accountForReadyGenerationExit(pid, readyAt, code, signal) {
|
|
1652
|
+
const readyMs = readyAt === undefined ? 0 : this.monotonicNow() - readyAt;
|
|
1653
|
+
const stoodDownImmediately = code === 0 && signal === null && readyMs < MIN_HEALTHY_WORKER_READY_MS;
|
|
1654
|
+
if (!stoodDownImmediately) {
|
|
1655
|
+
this.consecutiveImmediateStandDowns = 0;
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
this.consecutiveImmediateStandDowns += 1;
|
|
1659
|
+
this.restartBackoff.escalateTo(this.consecutiveImmediateStandDowns);
|
|
1660
|
+
this.eventWriter("warn", "supervisor.worker_stood_down_immediately", {
|
|
1661
|
+
pid,
|
|
1662
|
+
readyMs,
|
|
1663
|
+
consecutive: this.consecutiveImmediateStandDowns,
|
|
1664
|
+
healthyReadyMs: MIN_HEALTHY_WORKER_READY_MS
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
958
1667
|
handleWorkerMessage(child, message) {
|
|
959
1668
|
if (child !== this.child)
|
|
960
1669
|
return;
|
|
@@ -972,12 +1681,16 @@ export class StdioSupervisor {
|
|
|
972
1681
|
}
|
|
973
1682
|
if (this.isInitializationResponse(message)) {
|
|
974
1683
|
const id = getTrackedRequestId(message);
|
|
1684
|
+
let initializeMode;
|
|
975
1685
|
if (id !== undefined) {
|
|
976
|
-
|
|
1686
|
+
const key = requestKey(id);
|
|
1687
|
+
initializeMode = this.pendingRequests.get(key)?.mode;
|
|
1688
|
+
this.pendingRequests.delete(key);
|
|
977
1689
|
}
|
|
1690
|
+
initializeMode ??= this.modeForMessage(this.initializeRequest);
|
|
978
1691
|
if ("error" in message) {
|
|
979
1692
|
if (!this.replayingInitialization && id !== undefined) {
|
|
980
|
-
this.
|
|
1693
|
+
this.writeSyntheticReply({ id, era: this.era, mode: initializeMode }, buildLegacyJsonRpcError(id));
|
|
981
1694
|
const retainedIndex = this.queuedNotifications.findIndex((entry) => isRequest(entry) && requestKey(entry.id) === requestKey(id));
|
|
982
1695
|
if (retainedIndex >= 0)
|
|
983
1696
|
this.queuedNotifications.splice(retainedIndex, 1);
|
|
@@ -1001,15 +1714,32 @@ export class StdioSupervisor {
|
|
|
1001
1714
|
}
|
|
1002
1715
|
this.clientInitialized = true;
|
|
1003
1716
|
this.adoptActiveChild();
|
|
1004
|
-
this.writeToClient(message);
|
|
1717
|
+
this.writeToClient(message, initializeMode);
|
|
1005
1718
|
this.flushQueue();
|
|
1006
1719
|
return;
|
|
1007
1720
|
}
|
|
1721
|
+
// Worker messages that correlate to no tracked request (server-originated
|
|
1722
|
+
// notifications/requests, untracked-id responses with no tombstone) fall
|
|
1723
|
+
// back to the last-detected inbound mode in writeToClient. A response for
|
|
1724
|
+
// a TOMBSTONED id is different: the supervisor already synthesized the
|
|
1725
|
+
// terminal response for that request instance, so the late answer is
|
|
1726
|
+
// discarded — exactly one response per id.
|
|
1727
|
+
let responseMode;
|
|
1008
1728
|
if (isResponse(message)) {
|
|
1009
1729
|
const id = getTrackedRequestId(message);
|
|
1010
1730
|
if (id !== undefined) {
|
|
1011
1731
|
const key = requestKey(id);
|
|
1732
|
+
const tombstone = this.syntheticTombstones.get(key);
|
|
1733
|
+
if (tombstone) {
|
|
1734
|
+
this.eventWriter("warn", "supervisor.late_response_discarded", {
|
|
1735
|
+
id,
|
|
1736
|
+
tombstoneGeneration: tombstone.generation,
|
|
1737
|
+
currentGeneration: this.workerGeneration
|
|
1738
|
+
});
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1012
1741
|
const pending = this.pendingRequests.get(key);
|
|
1742
|
+
responseMode = pending?.mode;
|
|
1013
1743
|
this.pendingRequests.delete(key);
|
|
1014
1744
|
if (pending?.deadlineTimer)
|
|
1015
1745
|
this.timerClearer(pending.deadlineTimer);
|
|
@@ -1017,14 +1747,10 @@ export class StdioSupervisor {
|
|
|
1017
1747
|
this.runningValidateKey = undefined;
|
|
1018
1748
|
if (this.validateBarrierKey === key)
|
|
1019
1749
|
this.validateBarrierKey = undefined;
|
|
1020
|
-
if (pending.clientCancelled) {
|
|
1021
|
-
this.drainQueue();
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
1750
|
}
|
|
1025
1751
|
}
|
|
1026
1752
|
}
|
|
1027
|
-
this.writeToClient(message);
|
|
1753
|
+
this.writeToClient(message, responseMode);
|
|
1028
1754
|
this.drainQueue();
|
|
1029
1755
|
}
|
|
1030
1756
|
applyStageUpdate(params) {
|
|
@@ -1061,11 +1787,20 @@ export class StdioSupervisor {
|
|
|
1061
1787
|
hasInitializeRequest: this.initializeRequest !== undefined,
|
|
1062
1788
|
clientInitialized: this.clientInitialized
|
|
1063
1789
|
});
|
|
1064
|
-
|
|
1790
|
+
// Era-gated replay: initialize replay fires ONLY in the legacy era. The
|
|
1791
|
+
// modern lock purges the cached lifecycle, so the era check is
|
|
1792
|
+
// belt-and-braces — no initialize may ever reach a modern-locked worker.
|
|
1793
|
+
if (this.era !== "legacy" || !this.initializeRequest) {
|
|
1065
1794
|
this.adoptActiveChild();
|
|
1066
1795
|
this.flushQueue();
|
|
1067
1796
|
return;
|
|
1068
1797
|
}
|
|
1798
|
+
while (this.initializePredecessorCount > 0 && this.queuedRequests.length > 0) {
|
|
1799
|
+
const predecessor = this.queuedRequests.shift();
|
|
1800
|
+
this.initializePredecessorCount -= 1;
|
|
1801
|
+
this.forwardRequest(predecessor.message, predecessor.pending);
|
|
1802
|
+
}
|
|
1803
|
+
this.initializePredecessorCount = 0;
|
|
1069
1804
|
this.replayingInitialization = this.clientInitialized;
|
|
1070
1805
|
this.forwardRequest(this.initializeRequest, this.createPendingRequest(this.initializeRequest));
|
|
1071
1806
|
}
|
|
@@ -1125,34 +1860,121 @@ export class StdioSupervisor {
|
|
|
1125
1860
|
for (const [key, pending] of [...this.pendingRequests.entries()]) {
|
|
1126
1861
|
if (key === preservedInitializeKey)
|
|
1127
1862
|
continue;
|
|
1128
|
-
this.pendingRequests.delete(key);
|
|
1129
1863
|
if (pending.deadlineTimer)
|
|
1130
1864
|
this.timerClearer(pending.deadlineTimer);
|
|
1131
1865
|
if (this.runningValidateKey === key)
|
|
1132
1866
|
this.runningValidateKey = undefined;
|
|
1133
1867
|
if (this.validateBarrierKey === key)
|
|
1134
1868
|
this.validateBarrierKey = undefined;
|
|
1135
|
-
|
|
1136
|
-
|
|
1869
|
+
// Forwarded entries stay in pendingRequests until writeSyntheticReply
|
|
1870
|
+
// settles them (the FORWARDED pending is what entitles the id to a
|
|
1871
|
+
// finality tombstone). Cancelled entries are already gone — the
|
|
1872
|
+
// cancellation settled them terminally at admission.
|
|
1137
1873
|
const toolName = pending.toolName ?? "unknown";
|
|
1138
1874
|
const pruned = prunedByTool.get(toolName) ?? [];
|
|
1139
1875
|
const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
|
|
1140
|
-
this.
|
|
1876
|
+
this.writeSyntheticReply(pending, reply);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* The single terminal-synthesis writer: EVERY supervisor-synthesized
|
|
1881
|
+
* response (queue-limit, validate-project timeout, worker-restart in all
|
|
1882
|
+
* variants incl. the structured-restart toggle, startup-failure
|
|
1883
|
+
* terminalization, initialize-failure -32603) routes through here.
|
|
1884
|
+
* Responsibilities, in order:
|
|
1885
|
+
* 1. settle the pending entry,
|
|
1886
|
+
* 2. record the finality tombstone (id + originating worker generation +
|
|
1887
|
+
* framing mode) so a late worker response for the id is discarded —
|
|
1888
|
+
* ONLY when the id was actually FORWARDED to a worker (i.e. the entry
|
|
1889
|
+
* in pendingRequests at the id IS the snapshot instance being
|
|
1890
|
+
* answered; an id-colliding DIFFERENT live entry is left untouched).
|
|
1891
|
+
* Never-forwarded
|
|
1892
|
+
* rejections (queue-limit at admission and at the forward fallback,
|
|
1893
|
+
* queue-phase validate timeout, cap-blocked replies, startup-failure
|
|
1894
|
+
* terminalization of queued work, the retained-initialize error) record
|
|
1895
|
+
* NO tombstone: no worker ever saw those ids, so no worker can answer
|
|
1896
|
+
* them, and tombstoning them would let a client grow the map without
|
|
1897
|
+
* bound inside a healthy generation,
|
|
1898
|
+
* 3. apply modern-era decoration — `snapshot.era` is the ONLY
|
|
1899
|
+
* captured-context field read; raw error envelopes and non-modern eras
|
|
1900
|
+
* pass through byte-identical — and
|
|
1901
|
+
* 4. write in the ORIGINATING request's framing.
|
|
1902
|
+
* Admission-time era rejections (era_conflict/missing_meta/-32601) do NOT
|
|
1903
|
+
* route through here: they reject before any forwarding could happen, so
|
|
1904
|
+
* no worker can ever answer those ids. The initialize-error synthesis also
|
|
1905
|
+
* records no tombstone: isInitializationResponse settles its pending entry
|
|
1906
|
+
* before synthesizing, the answering generation is terminated, and the
|
|
1907
|
+
* replay cache is cleared — no late-response channel remains.
|
|
1908
|
+
*/
|
|
1909
|
+
writeSyntheticReply(snapshot, reply) {
|
|
1910
|
+
const key = requestKey(snapshot.id);
|
|
1911
|
+
const pending = this.pendingRequests.get(key);
|
|
1912
|
+
// Settle ONLY the exact forwarded instance this synthesis answers. A
|
|
1913
|
+
// never-forwarded snapshot (queue-limit, cap-blocked, intercept, queued
|
|
1914
|
+
// terminalization, ad-hoc literals) whose id collides with a DIFFERENT
|
|
1915
|
+
// live forwarded request must not delete or tombstone that live entry:
|
|
1916
|
+
// the tombstone would discard the worker's real answer and strand the
|
|
1917
|
+
// validate barrier (id reuse is a client-side JSON-RPC violation, but
|
|
1918
|
+
// the live request keeps its exactly-one-response guarantee).
|
|
1919
|
+
if (pending && pending === snapshot) {
|
|
1920
|
+
if (pending.deadlineTimer)
|
|
1921
|
+
this.timerClearer(pending.deadlineTimer);
|
|
1922
|
+
this.pendingRequests.delete(key);
|
|
1923
|
+
this.recordFinalityTombstone(key, snapshot.mode);
|
|
1924
|
+
}
|
|
1925
|
+
this.writeToClient(decorateSyntheticReply(reply, snapshot.era), snapshot.mode);
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Records a response-finality tombstone for a request instance the
|
|
1929
|
+
* supervisor has terminally settled (synthesized reply or client
|
|
1930
|
+
* cancellation). Insertion-order eviction at MAX_SYNTHETIC_TOMBSTONES keeps
|
|
1931
|
+
* the map bounded even inside one healthy worker generation, where the
|
|
1932
|
+
* per-generation purge cannot fire.
|
|
1933
|
+
*/
|
|
1934
|
+
recordFinalityTombstone(key, mode) {
|
|
1935
|
+
// Re-set moves an existing key to the end of the insertion order, so the
|
|
1936
|
+
// eviction victim is always the least recently recorded tombstone.
|
|
1937
|
+
this.syntheticTombstones.delete(key);
|
|
1938
|
+
this.syntheticTombstones.set(key, { generation: this.workerGeneration, mode });
|
|
1939
|
+
while (this.syntheticTombstones.size > MAX_SYNTHETIC_TOMBSTONES) {
|
|
1940
|
+
const oldest = this.syntheticTombstones.keys().next();
|
|
1941
|
+
if (oldest.done)
|
|
1942
|
+
break;
|
|
1943
|
+
this.syntheticTombstones.delete(oldest.value);
|
|
1944
|
+
debugSupervisor("tombstone_evicted", { key: oldest.value, cap: MAX_SYNTHETIC_TOMBSTONES });
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Retention bound for finality tombstones: generation `generation` is gone
|
|
1949
|
+
* with streams closed, so every tombstone recorded at or before it is
|
|
1950
|
+
* unreachable (see syntheticTombstones doc) and may be dropped.
|
|
1951
|
+
*/
|
|
1952
|
+
purgeTombstonesThroughGeneration(generation) {
|
|
1953
|
+
for (const [key, tombstone] of this.syntheticTombstones) {
|
|
1954
|
+
if (tombstone.generation <= generation) {
|
|
1955
|
+
this.syntheticTombstones.delete(key);
|
|
1956
|
+
}
|
|
1141
1957
|
}
|
|
1142
1958
|
}
|
|
1143
|
-
|
|
1959
|
+
/**
|
|
1960
|
+
* Writes one client-bound message. `mode` is the ORIGINATING REQUEST's
|
|
1961
|
+
* captured framing; callers omit it only for writes that correlate to no
|
|
1962
|
+
* request id, which fall back to the last-detected inbound mode.
|
|
1963
|
+
*/
|
|
1964
|
+
writeToClient(message, mode) {
|
|
1965
|
+
const effectiveMode = mode ?? this.lastInboundClientMode;
|
|
1144
1966
|
debugSupervisor("write_to_client", {
|
|
1145
1967
|
hasMethod: "method" in message,
|
|
1146
1968
|
method: "method" in message ? message.method : undefined,
|
|
1147
1969
|
id: "id" in message ? message.id : undefined,
|
|
1148
|
-
clientMode:
|
|
1970
|
+
clientMode: effectiveMode
|
|
1149
1971
|
});
|
|
1150
1972
|
try {
|
|
1151
1973
|
if (this.clientWriter) {
|
|
1152
1974
|
this.clientWriter(message);
|
|
1153
1975
|
return;
|
|
1154
1976
|
}
|
|
1155
|
-
const frame = encodeJsonRpcMessage(message,
|
|
1977
|
+
const frame = encodeJsonRpcMessage(message, effectiveMode);
|
|
1156
1978
|
process.stdout.write(frame);
|
|
1157
1979
|
}
|
|
1158
1980
|
catch (error) {
|
|
@@ -1164,14 +1986,33 @@ export class StdioSupervisor {
|
|
|
1164
1986
|
clearInitialInitializationState() {
|
|
1165
1987
|
if (this.clientInitialized)
|
|
1166
1988
|
return;
|
|
1989
|
+
if (this.initializeRequest) {
|
|
1990
|
+
// A preserved not-yet-answered initialize map entry (kept across worker
|
|
1991
|
+
// exit for replay correlation) is orphaned once the cached handshake is
|
|
1992
|
+
// discarded: no replay can ever answer it, and leaving it would block
|
|
1993
|
+
// validate-project dispatch forever and resurface at the next worker
|
|
1994
|
+
// exit as a duplicate reply. Drop it without a tombstone — its
|
|
1995
|
+
// generation is gone, so no late answer can arrive.
|
|
1996
|
+
const staleKey = requestKey(this.initializeRequest.id);
|
|
1997
|
+
const stale = this.pendingRequests.get(staleKey);
|
|
1998
|
+
if (stale?.method === "initialize") {
|
|
1999
|
+
if (stale.deadlineTimer)
|
|
2000
|
+
this.timerClearer(stale.deadlineTimer);
|
|
2001
|
+
this.pendingRequests.delete(staleKey);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
1167
2004
|
this.initializeRequest = undefined;
|
|
1168
2005
|
this.initializedNotification = undefined;
|
|
2006
|
+
this.initializePredecessorCount = 0;
|
|
1169
2007
|
this.replayingInitialization = false;
|
|
1170
2008
|
this.initializeSentToWorker = false;
|
|
1171
2009
|
}
|
|
1172
2010
|
liveCapOccupancy() {
|
|
1173
2011
|
return this.liveChildren.size + this.unresolvedTreeTokens.size;
|
|
1174
2012
|
}
|
|
2013
|
+
get unresolvedTreeTokenCount() {
|
|
2014
|
+
return this.unresolvedTreeTokens.size;
|
|
2015
|
+
}
|
|
1175
2016
|
clearStartupWatchdog() {
|
|
1176
2017
|
if (this.startupWatchdog) {
|
|
1177
2018
|
this.timerClearer(this.startupWatchdog);
|
|
@@ -1181,6 +2022,7 @@ export class StdioSupervisor {
|
|
|
1181
2022
|
adoptActiveChild() {
|
|
1182
2023
|
this.clearStartupWatchdog();
|
|
1183
2024
|
this.childReady = true;
|
|
2025
|
+
this.childReadyAt ??= this.monotonicNow();
|
|
1184
2026
|
this.restartBackoff.reset();
|
|
1185
2027
|
this.currentRetryEpoch = undefined;
|
|
1186
2028
|
this.currentRetryReservation = undefined;
|
|
@@ -1192,6 +2034,7 @@ export class StdioSupervisor {
|
|
|
1192
2034
|
this.clearStartupWatchdog();
|
|
1193
2035
|
this.child = undefined;
|
|
1194
2036
|
this.childReady = false;
|
|
2037
|
+
this.childReadyAt = undefined;
|
|
1195
2038
|
this.replayingInitialization = false;
|
|
1196
2039
|
this.initializeSentToWorker = false;
|
|
1197
2040
|
this.staleChildren.add(child);
|
|
@@ -1205,6 +2048,7 @@ export class StdioSupervisor {
|
|
|
1205
2048
|
this.clearStartupWatchdog();
|
|
1206
2049
|
this.child = undefined;
|
|
1207
2050
|
this.childReady = false;
|
|
2051
|
+
this.childReadyAt = undefined;
|
|
1208
2052
|
this.replayingInitialization = false;
|
|
1209
2053
|
this.initializeSentToWorker = false;
|
|
1210
2054
|
if (child) {
|
|
@@ -1328,15 +2172,64 @@ export class StdioSupervisor {
|
|
|
1328
2172
|
return;
|
|
1329
2173
|
if (success) {
|
|
1330
2174
|
this.unresolvedTreeTokens.delete(cleanup.pid);
|
|
2175
|
+
this.cleanupRetryAttempts.delete(cleanup.pid);
|
|
1331
2176
|
this.cleanupStates.delete(child);
|
|
2177
|
+
this.stopCleanupTokenRetryIfIdle();
|
|
1332
2178
|
}
|
|
1333
2179
|
else {
|
|
1334
2180
|
this.unresolvedTreeTokens.add(cleanup.pid);
|
|
2181
|
+
this.scheduleCleanupTokenRetry();
|
|
1335
2182
|
}
|
|
1336
2183
|
this.resumePausedRestart();
|
|
1337
2184
|
this.drainQueue();
|
|
1338
2185
|
}
|
|
1339
|
-
|
|
2186
|
+
scheduleCleanupTokenRetry() {
|
|
2187
|
+
if (this.shuttingDown || this.cleanupRetryTimer || this.unresolvedTreeTokens.size === 0)
|
|
2188
|
+
return;
|
|
2189
|
+
const delayMs = Math.min(this.cleanupTokenRetryBaseMs * (2 ** this.cleanupRetryIndex), this.cleanupTokenRetryCapMs);
|
|
2190
|
+
this.cleanupRetryIndex += 1;
|
|
2191
|
+
this.cleanupRetryTimer = this.timerScheduler(() => {
|
|
2192
|
+
this.cleanupRetryTimer = undefined;
|
|
2193
|
+
void this.runCleanupTokenRetries();
|
|
2194
|
+
}, delayMs);
|
|
2195
|
+
this.cleanupRetryTimer.unref();
|
|
2196
|
+
}
|
|
2197
|
+
async runCleanupTokenRetries() {
|
|
2198
|
+
await Promise.all([...this.unresolvedTreeTokens].map((pid) => this.retryUnresolvedTreeToken(pid)));
|
|
2199
|
+
if (this.unresolvedTreeTokens.size === 0) {
|
|
2200
|
+
this.cleanupRetryIndex = 0;
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
this.scheduleCleanupTokenRetry();
|
|
2204
|
+
}
|
|
2205
|
+
stopCleanupTokenRetryIfIdle() {
|
|
2206
|
+
if (this.unresolvedTreeTokens.size > 0)
|
|
2207
|
+
return;
|
|
2208
|
+
if (this.cleanupRetryTimer) {
|
|
2209
|
+
this.timerClearer(this.cleanupRetryTimer);
|
|
2210
|
+
this.cleanupRetryTimer = undefined;
|
|
2211
|
+
}
|
|
2212
|
+
this.cleanupRetryIndex = 0;
|
|
2213
|
+
this.cleanupRetryAttempts.clear();
|
|
2214
|
+
}
|
|
2215
|
+
retryUnresolvedTreeToken(pid) {
|
|
2216
|
+
const existing = this.cleanupRetriesInFlight.get(pid);
|
|
2217
|
+
if (existing)
|
|
2218
|
+
return existing;
|
|
2219
|
+
const retry = this.performUnresolvedTreeTokenRetry(pid);
|
|
2220
|
+
this.cleanupRetriesInFlight.set(pid, retry);
|
|
2221
|
+
return retry.finally(() => {
|
|
2222
|
+
if (this.cleanupRetriesInFlight.get(pid) === retry) {
|
|
2223
|
+
this.cleanupRetriesInFlight.delete(pid);
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
async performUnresolvedTreeTokenRetry(pid) {
|
|
2228
|
+
if (!this.unresolvedTreeTokens.has(pid))
|
|
2229
|
+
return;
|
|
2230
|
+
const attempt = (this.cleanupRetryAttempts.get(pid) ?? 0) + 1;
|
|
2231
|
+
this.cleanupRetryAttempts.set(pid, attempt);
|
|
2232
|
+
this.eventWriter("warn", "supervisor.cleanup_token.retry", { pid, attempt });
|
|
1340
2233
|
let success = false;
|
|
1341
2234
|
if (this.treeTokenRetrier) {
|
|
1342
2235
|
success = await settleTreeCleanupWithin(Promise.resolve().then(() => this.treeTokenRetrier(pid)), this.treeCleanupTimeoutMs);
|
|
@@ -1374,14 +2267,23 @@ export class StdioSupervisor {
|
|
|
1374
2267
|
catch { /* best effort */ }
|
|
1375
2268
|
});
|
|
1376
2269
|
}
|
|
1377
|
-
if (!success)
|
|
2270
|
+
if (!success || !this.unresolvedTreeTokens.has(pid))
|
|
1378
2271
|
return;
|
|
1379
2272
|
this.unresolvedTreeTokens.delete(pid);
|
|
2273
|
+
this.cleanupRetryAttempts.delete(pid);
|
|
1380
2274
|
for (const [child, cleanup] of this.cleanupStates) {
|
|
1381
2275
|
if (cleanup.pid === pid && cleanup.parentExited) {
|
|
1382
2276
|
this.cleanupStates.delete(child);
|
|
1383
2277
|
}
|
|
1384
2278
|
}
|
|
2279
|
+
this.eventWriter("info", "supervisor.cleanup_token.recovered", {
|
|
2280
|
+
pid,
|
|
2281
|
+
attempt,
|
|
2282
|
+
unresolvedTreeTokens: this.unresolvedTreeTokens.size
|
|
2283
|
+
});
|
|
2284
|
+
this.stopCleanupTokenRetryIfIdle();
|
|
2285
|
+
this.resumePausedRestart();
|
|
2286
|
+
this.drainQueue();
|
|
1385
2287
|
}
|
|
1386
2288
|
handleStartupFailure(token, exit) {
|
|
1387
2289
|
if (token !== this.attemptToken && this.child !== undefined)
|
|
@@ -1399,12 +2301,16 @@ export class StdioSupervisor {
|
|
|
1399
2301
|
const { reply } = buildWorkerRestartReply(pending, exit, now, [], {
|
|
1400
2302
|
structuredRestartDisabled: STRUCTURED_RESTART_DISABLED
|
|
1401
2303
|
});
|
|
1402
|
-
this.
|
|
2304
|
+
this.writeSyntheticReply(pending, reply);
|
|
1403
2305
|
}
|
|
1404
2306
|
const retainedInitialize = this.queuedNotifications.find((message) => isRequest(message) && message.method === "initialize");
|
|
1405
2307
|
this.queuedNotifications.splice(0, this.queuedNotifications.length);
|
|
1406
2308
|
if (retainedInitialize && isRequest(retainedInitialize)) {
|
|
1407
|
-
this.
|
|
2309
|
+
this.writeSyntheticReply({
|
|
2310
|
+
id: retainedInitialize.id,
|
|
2311
|
+
era: this.era,
|
|
2312
|
+
mode: this.modeForMessage(retainedInitialize)
|
|
2313
|
+
}, buildLegacyJsonRpcError(retainedInitialize.id));
|
|
1408
2314
|
}
|
|
1409
2315
|
this.clearInitialInitializationState();
|
|
1410
2316
|
this.validateBarrierKey = undefined;
|
|
@@ -1419,6 +2325,13 @@ export class StdioSupervisor {
|
|
|
1419
2325
|
this.currentRetryEpoch = reservation.epoch;
|
|
1420
2326
|
if (this.liveCapOccupancy() >= 2) {
|
|
1421
2327
|
this.retryPaused = true;
|
|
2328
|
+
this.eventWriter("warn", "supervisor.live_cap.saturated", {
|
|
2329
|
+
occupancy: this.liveCapOccupancy(),
|
|
2330
|
+
cap: 2,
|
|
2331
|
+
liveChildren: this.liveChildren.size,
|
|
2332
|
+
unresolvedTreeTokens: this.unresolvedTreeTokens.size,
|
|
2333
|
+
reason: "restart-blocked"
|
|
2334
|
+
});
|
|
1422
2335
|
return;
|
|
1423
2336
|
}
|
|
1424
2337
|
this.armRestartReservation(reservation);
|
|
@@ -1458,11 +2371,25 @@ export class StdioSupervisor {
|
|
|
1458
2371
|
return;
|
|
1459
2372
|
this.armRestartReservation(reservation);
|
|
1460
2373
|
}
|
|
1461
|
-
|
|
2374
|
+
/**
|
|
2375
|
+
* Idempotent teardown. Every caller receives the SAME promise, so a caller
|
|
2376
|
+
* that needs to know when cleanup is really finished — the fatal handler,
|
|
2377
|
+
* which then ends the process — cannot be handed an already-resolved promise
|
|
2378
|
+
* while another teardown is still collecting the worker's process group.
|
|
2379
|
+
*/
|
|
2380
|
+
shutdown() {
|
|
2381
|
+
this.shutdownRun ??= this.runShutdown();
|
|
2382
|
+
return this.shutdownRun;
|
|
2383
|
+
}
|
|
2384
|
+
async runShutdown() {
|
|
1462
2385
|
if (this.shuttingDown) {
|
|
1463
2386
|
return;
|
|
1464
2387
|
}
|
|
1465
2388
|
this.shuttingDown = true;
|
|
2389
|
+
if (this.cleanupRetryTimer) {
|
|
2390
|
+
this.timerClearer(this.cleanupRetryTimer);
|
|
2391
|
+
this.cleanupRetryTimer = undefined;
|
|
2392
|
+
}
|
|
1466
2393
|
if (this.restartTimer) {
|
|
1467
2394
|
this.timerClearer(this.restartTimer);
|
|
1468
2395
|
this.restartTimer = undefined;
|
|
@@ -1481,17 +2408,41 @@ export class StdioSupervisor {
|
|
|
1481
2408
|
}
|
|
1482
2409
|
this.pendingRequests.clear();
|
|
1483
2410
|
this.queuedRequests.splice(0, this.queuedRequests.length);
|
|
2411
|
+
this.syntheticTombstones.clear();
|
|
1484
2412
|
process.stdin.off("data", this.handleClientData);
|
|
1485
2413
|
process.stdin.off("error", this.handleClientError);
|
|
1486
2414
|
process.stdin.off("end", this.handleClientClosed);
|
|
1487
2415
|
process.stdin.off("close", this.handleClientClosed);
|
|
2416
|
+
// Symmetric with start()'s resume(). Without this a shutdown triggered
|
|
2417
|
+
// while stdin is still OPEN (a framing-fatal teardown, a signal) leaves the
|
|
2418
|
+
// stdin handle referenced and the process alive with nothing left to serve;
|
|
2419
|
+
// the ordinary shutdown path never noticed because stdin had already ended.
|
|
2420
|
+
// pause() stops the flow, unref() releases the event-loop reference (absent
|
|
2421
|
+
// on a file-backed stdin, hence the guard).
|
|
2422
|
+
process.stdin.pause();
|
|
2423
|
+
const unrefStdin = process.stdin.unref;
|
|
2424
|
+
if (typeof unrefStdin === "function") {
|
|
2425
|
+
unrefStdin.call(process.stdin);
|
|
2426
|
+
}
|
|
1488
2427
|
process.off("SIGINT", this.handleTerminateSignal);
|
|
1489
2428
|
process.off("SIGTERM", this.handleTerminateSignal);
|
|
2429
|
+
process.off("SIGHUP", this.handleTerminateSignal);
|
|
1490
2430
|
this.detachCurrentChild();
|
|
1491
2431
|
for (const child of this.liveChildren) {
|
|
1492
2432
|
this.beginTreeTermination(child);
|
|
1493
2433
|
}
|
|
1494
|
-
|
|
2434
|
+
try {
|
|
2435
|
+
await Promise.all([...this.unresolvedTreeTokens].map((pid) => this.retryUnresolvedTreeToken(pid)));
|
|
2436
|
+
}
|
|
2437
|
+
finally {
|
|
2438
|
+
// Deregistered only once the teardown TAIL is done. Dropping them before
|
|
2439
|
+
// the await left a rejection raised while the worker group was still
|
|
2440
|
+
// being collected with nowhere to go: node's default handler is
|
|
2441
|
+
// suppressed for the lifetime of a registration, and this one had
|
|
2442
|
+
// already been withdrawn.
|
|
2443
|
+
process.off("uncaughtException", this.handleFatalError);
|
|
2444
|
+
process.off("unhandledRejection", this.handleFatalError);
|
|
2445
|
+
}
|
|
1495
2446
|
}
|
|
1496
2447
|
}
|
|
1497
2448
|
export const STDIO_WORKER_MODE_ENV = WORKER_MODE_ENV;
|