breakpoint-mcp 1.78.0 → 1.78.2
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/dist/bridge.js +64 -13
- package/dist/close-cause.js +98 -0
- package/dist/connect-cause.js +124 -0
- package/dist/csdap.js +19 -3
- package/dist/cslsp.js +19 -2
- package/dist/dap.js +19 -3
- package/dist/index.js +4 -2
- package/dist/lsp.js +19 -3
- package/dist/peers.js +38 -10
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -4,13 +4,24 @@ import { log } from "./logger.js";
|
|
|
4
4
|
import { OverdueLedger } from "./late-reply.js";
|
|
5
5
|
import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS } from "./finiteness.js";
|
|
6
6
|
import { remedyForWireError } from "./remedies.js";
|
|
7
|
+
import { closeDetail, closeRemedy } from "./close-cause.js";
|
|
8
|
+
import { connectHint, connectRemedy } from "./connect-cause.js";
|
|
7
9
|
export class BridgeError extends Error {
|
|
8
10
|
code;
|
|
9
11
|
/**
|
|
10
12
|
* The next action the addon attached to this code (254). Optional by
|
|
11
|
-
* construction: the addon omits it for codes it has nothing to say about
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* construction: the addon omits it for codes it has nothing to say about.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 THE REST OF THIS SENTENCE WAS WRONG FOR TWO SESSIONS, AND 264 MEASURED IT. It
|
|
16
|
+
* read: *every failure the host raises itself — timeout, closed socket, non-finite —
|
|
17
|
+
* arrives without one, because the remedy for those is not the addon's to give.* The
|
|
18
|
+
* because-clause is true and the claim does not follow from it. 262 answered `timeout`;
|
|
19
|
+
* 264 answered `bridge_closed` off an errno that had been in hand at the close site the
|
|
20
|
+
* whole time. The census counted the rest: of 25 host-raised failures about the world,
|
|
21
|
+
* ONE carried a remedy in this field, SEVEN named a next action in their message where
|
|
22
|
+
* no reader counts it, and SEVENTEEN said nothing while holding an errno, a state
|
|
23
|
+
* machine or a registry the host owns. **Absent here means nobody has answered it yet,
|
|
24
|
+
* not that it is unanswerable.**
|
|
14
25
|
*/
|
|
15
26
|
remedy;
|
|
16
27
|
constructor(code, message, remedy) {
|
|
@@ -111,7 +122,17 @@ export class BridgeClient {
|
|
|
111
122
|
* which is worse than saying nothing. Defaults keep the editor bridge's
|
|
112
123
|
* wording byte-identical to what shipped.
|
|
113
124
|
*/
|
|
114
|
-
deadlineKnob = "BREAKPOINT_BRIDGE_TIMEOUT_MS", peerNoun = "the editor"
|
|
125
|
+
deadlineKnob = "BREAKPOINT_BRIDGE_TIMEOUT_MS", peerNoun = "the editor",
|
|
126
|
+
/**
|
|
127
|
+
* The env var that names THIS instance's HOST, for the same per-INSTANCE reason
|
|
128
|
+
* `deadlineKnob` exists (265). The editor bridge takes its address from
|
|
129
|
+
* BREAKPOINT_BRIDGE_HOST and the runtime bridge and its peers from
|
|
130
|
+
* BREAKPOINT_RUNTIME_HOST; an unresolved-host remedy naming the wrong one sends the
|
|
131
|
+
* operator to a knob that cannot move the address they just failed to reach — the
|
|
132
|
+
* identical defect the late-reply line was given its own knob to avoid. Defaults keep
|
|
133
|
+
* the editor bridge's wording byte-identical to what shipped.
|
|
134
|
+
*/
|
|
135
|
+
hostKnob = "BREAKPOINT_BRIDGE_HOST") {
|
|
115
136
|
this.host = host;
|
|
116
137
|
this.port = port;
|
|
117
138
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
@@ -119,14 +140,25 @@ export class BridgeClient {
|
|
|
119
140
|
this.hint = hint;
|
|
120
141
|
this.secretProvider = secretProvider;
|
|
121
142
|
this.ledger = new OverdueLedger("bridge", peerNoun, deadlineKnob);
|
|
143
|
+
// 🔴 KEPT, not just handed to the ledger (264). A dropped connection names the same
|
|
144
|
+
// peer a late reply does, and the two sentences drifting apart would be a bug nobody
|
|
145
|
+
// could see from either side.
|
|
146
|
+
this.peerNoun = peerNoun;
|
|
147
|
+
this.hostKnob = hostKnob;
|
|
122
148
|
}
|
|
149
|
+
/** Who answers this client — read by the close path and the late-reply ledger alike. */
|
|
150
|
+
peerNoun;
|
|
151
|
+
/** The env var that moves this client's host — read by the connect path (265). */
|
|
152
|
+
hostKnob;
|
|
123
153
|
/**
|
|
124
154
|
* A cause this client cannot see, supplied by whoever CAN, as the remedy on a timeout.
|
|
125
155
|
*
|
|
126
156
|
* 🔴 WHY THIS EXISTS AT ALL — the eighth session of "the host's own failures carry no
|
|
127
157
|
* remedy" (254 §4.6 → 261 §4.4), answered for the one case a walk actually produced.
|
|
128
158
|
* `BridgeError.remedy` was documented as "the addon's to give, and a host-raised
|
|
129
|
-
* timeout arrives without one".
|
|
159
|
+
* timeout arrives without one". 🔴 264 DISPROVED THE "TRUE FOR A CLOSED SOCKET" HALF —
|
|
160
|
+
* `onClose` had the errno in hand and read only its `message`, so a killed peer and an
|
|
161
|
+
* orderly shutdown arrived as one code and one sentence. NOT true here either, and USER_GUIDE
|
|
130
162
|
* §10 B's own step 5 walks the reader straight into it: the addon services `runtime_*`
|
|
131
163
|
* from `_process`, so a breakpoint inside the method being called halts the very frame
|
|
132
164
|
* that owes the reply. Measured — the stop's stack trace is `compute` on top of
|
|
@@ -197,7 +229,16 @@ export class BridgeClient {
|
|
|
197
229
|
// Recorded for onClose(): if the connection was already up, this reject()
|
|
198
230
|
// is a no-op on a settled promise and the errno would otherwise be lost.
|
|
199
231
|
this.lastSocketError = err;
|
|
200
|
-
|
|
232
|
+
// 🔴 THE ERRNO WAS ALREADY HERE AND ONLY ITS `message` WAS READ (265), which is
|
|
233
|
+
// 264's close-family finding one step earlier in the same file. `err.code`
|
|
234
|
+
// separates a port nothing is listening on from a host name that never resolved,
|
|
235
|
+
// and the hint below was appended to BOTH — telling a caller whose
|
|
236
|
+
// BREAKPOINT_*_HOST is a typo to go and look at Godot, for a connect where no
|
|
237
|
+
// packet ever left this machine. `connectHint` suppresses the hint in exactly
|
|
238
|
+
// that case and returns it untouched otherwise, so every other message a caller
|
|
239
|
+
// reads is byte-identical to what shipped. See connect-cause.ts.
|
|
240
|
+
const hint = connectHint(err, this.hint);
|
|
241
|
+
reject(new BridgeError(BRIDGE_UNAVAILABLE, `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}.${hint ? ` ${hint}` : ""} (${err.message})`, connectRemedy(err, this.peerNoun, this.hostKnob)));
|
|
201
242
|
});
|
|
202
243
|
socket.on("data", (chunk) => this.onData(chunk));
|
|
203
244
|
socket.on("close", () => this.onClose());
|
|
@@ -328,12 +369,18 @@ export class BridgeClient {
|
|
|
328
369
|
this.socket = null;
|
|
329
370
|
// Name the transport error when there was one. The CODE stays `bridge_closed`
|
|
330
371
|
// — callers and tests branch on it — but the message carries the errno.
|
|
331
|
-
const cause = this.lastSocketError;
|
|
372
|
+
const cause = this.lastSocketError ?? undefined;
|
|
332
373
|
this.lastSocketError = null;
|
|
333
|
-
const detail =
|
|
374
|
+
const detail = closeDetail(cause);
|
|
375
|
+
// 🔴 THE ERRNO WAS ALREADY HERE AND ONLY ITS `message` WAS READ (264 §3). A peer that
|
|
376
|
+
// was killed and a peer that shut down cleanly produced the SAME code and the SAME
|
|
377
|
+
// sentence, differing by one parenthetical nothing instructed the caller to act on.
|
|
378
|
+
// `cause.code` separates them, and this is the one close site whose class has a field
|
|
379
|
+
// to put the answer in — see close-cause.ts for the four that do not.
|
|
380
|
+
const remedy = closeRemedy(cause, this.peerNoun);
|
|
334
381
|
for (const [, p] of this.pending) {
|
|
335
382
|
clearTimeout(p.timer);
|
|
336
|
-
p.reject(new BridgeError("bridge_closed", `Bridge connection closed before a response arrived${detail}
|
|
383
|
+
p.reject(new BridgeError("bridge_closed", `Bridge connection closed before a response arrived${detail}`, remedy));
|
|
337
384
|
}
|
|
338
385
|
this.pending.clear();
|
|
339
386
|
// Keep the push channel alive across editor restarts while subscriptions want it.
|
|
@@ -353,10 +400,14 @@ export class BridgeClient {
|
|
|
353
400
|
// exact `timed out after <n>ms` phrasing — tools/dap.ts:29 and
|
|
354
401
|
// tools/csdap.ts:31 branch on that substring.
|
|
355
402
|
this.ledger.note(id, method, timeoutMs);
|
|
356
|
-
// 🔴 THE ONE HOST-RAISED FAILURE THAT HAS A KNOWABLE CAUSE
|
|
357
|
-
// else this class raises
|
|
358
|
-
//
|
|
359
|
-
//
|
|
403
|
+
// 🔴 262 §2 CALLED THIS "THE ONE HOST-RAISED FAILURE THAT HAS A KNOWABLE CAUSE",
|
|
404
|
+
// and said everything else this class raises — "a closed socket, a peer that never
|
|
405
|
+
// answered" — is genuinely opaque from here. 264 MEASURED THAT AND IT IS FALSE FOR
|
|
406
|
+
// THE CLOSED SOCKET: `onClose` holds the socket `Error` and its errno separates a
|
|
407
|
+
// peer that was killed from one that shut down cleanly. It stays TRUE for this
|
|
408
|
+
// line — a peer that never answered really does leave nothing behind — which is
|
|
409
|
+
// why the one cause the host can supply comes from outside the socket: its own
|
|
410
|
+
// debugger holding the game. See `setHoldProbe`, and 264's census for the rest.
|
|
360
411
|
reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`, this.holdProbe?.() ?? undefined));
|
|
361
412
|
}, timeoutMs);
|
|
362
413
|
this.pending.set(id, { resolve: resolve, reject, timer, method });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a dropped connection's errno discriminates, and the next action for each.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 WHY THIS EXISTS — 264's census, which is the answer to a row nine sessions old.
|
|
5
|
+
* `BridgeError`'s own doc said every failure the host raises "arrives without one,
|
|
6
|
+
* because the remedy for those is not the addon's to give", and `setHoldProbe`'s said
|
|
7
|
+
* that was "true for a closed socket". **It is not true for a closed socket.** Measured
|
|
8
|
+
* against real sockets, three ways a peer can go:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* peer resetAndDestroy code=ECONNRESET syscall=read message="read ECONNRESET"
|
|
12
|
+
* peer end() cleanly NULL — no socket error at all
|
|
13
|
+
* peer destroy() NULL — no socket error at all
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* and what a caller received for the last two was the SAME code and the SAME sentence,
|
|
17
|
+
* separated by a parenthesis:
|
|
18
|
+
*
|
|
19
|
+
* ```
|
|
20
|
+
* B peer reset mid-request bridge_closed Bridge connection closed before a response arrived (read ECONNRESET)
|
|
21
|
+
* C peer closed cleanly bridge_closed Bridge connection closed before a response arrived
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* An editor that was killed and an addon that shut its socket on purpose are different
|
|
25
|
+
* next actions. The `Error` that tells them apart was already in hand at all five close
|
|
26
|
+
* sites — `framing.ts` hands the whole object to every one of them — and every one read
|
|
27
|
+
* `cause.message` and threw the rest away.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 ONE MODULE, FIVE CALLERS, BECAUSE THE ALTERNATIVE IS FIVE COPIES OF A JUDGEMENT.
|
|
30
|
+
* `bridge.ts`, `dap.ts`, `csdap.ts`, `lsp.ts` and `cslsp.ts` each drop a connection the
|
|
31
|
+
* same way for the same reasons. A sentence pasted into five close handlers is five
|
|
32
|
+
* places for the errno list to drift.
|
|
33
|
+
*
|
|
34
|
+
* 🔴 AND WHAT IT DELIBERATELY DOES NOT DO. An errno outside the abrupt family gets NO
|
|
35
|
+
* remedy — not a hedge, not a generic one. 264 measured two families and can speak for
|
|
36
|
+
* two; `classifyClose` returns `"unclassified"` for the rest so a future session can find
|
|
37
|
+
* the population it did not cover instead of inheriting a sentence nobody measured.
|
|
38
|
+
* 260's rule: a diagnostic that names a state is not a diagnosis of its cause.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Errnos that mean the peer went away WITHOUT closing the connection.
|
|
42
|
+
*
|
|
43
|
+
* ECONNRESET: the peer's stack sent RST — the process died or was killed mid-stream.
|
|
44
|
+
* EPIPE: we wrote to a connection the peer had already torn down.
|
|
45
|
+
* ECONNABORTED: the local stack aborted an established connection.
|
|
46
|
+
*
|
|
47
|
+
* All three say the same thing about the world — nobody hung up, somebody vanished —
|
|
48
|
+
* and none of them can be produced by an orderly shutdown.
|
|
49
|
+
*/
|
|
50
|
+
export const ABRUPT_CLOSE_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
|
|
51
|
+
/**
|
|
52
|
+
* Which of the three a close was. `undefined` means the socket ended with no error at
|
|
53
|
+
* all, which is what an orderly `end()` or `destroy()` looks like from this side.
|
|
54
|
+
*/
|
|
55
|
+
export function classifyClose(cause) {
|
|
56
|
+
if (!cause)
|
|
57
|
+
return "deliberate";
|
|
58
|
+
const code = cause.code;
|
|
59
|
+
if (typeof code === "string" && ABRUPT_CLOSE_CODES.has(code))
|
|
60
|
+
return "abrupt";
|
|
61
|
+
return "unclassified";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The next action, given who the peer was — or `undefined` when the errno does not
|
|
65
|
+
* determine one.
|
|
66
|
+
*
|
|
67
|
+
* Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
|
|
68
|
+
* head, a full stop at the end, and nothing named that does not resolve. `peer` is the
|
|
69
|
+
* noun each client already uses for whoever answers it — "the editor", "the running
|
|
70
|
+
* game", "the debug adapter", "the language server" — so the sentence names the thing the
|
|
71
|
+
* caller was actually talking to rather than a socket.
|
|
72
|
+
*/
|
|
73
|
+
export function closeRemedy(cause, peer) {
|
|
74
|
+
switch (classifyClose(cause)) {
|
|
75
|
+
case "abrupt":
|
|
76
|
+
// The errno is NOT repeated here: `closeDetail` has already put it in the sentence
|
|
77
|
+
// immediately to the left, and a caller reading `(read ECONNRESET) — … (ECONNRESET)`
|
|
78
|
+
// learns nothing from the second one.
|
|
79
|
+
return (`Restart ${peer} and retry — it went away without closing the connection, ` +
|
|
80
|
+
`so nothing here says whether this request ran.`);
|
|
81
|
+
case "deliberate":
|
|
82
|
+
return (`Check whether ${peer} is still running — it closed the connection in an orderly way ` +
|
|
83
|
+
`rather than dropping it, which is what a shutdown looks like from here.`);
|
|
84
|
+
default:
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The parenthetical the five close handlers already built by hand, spelled once.
|
|
90
|
+
*
|
|
91
|
+
* Kept byte-identical to what shipped — ` (read ECONNRESET)` — because tests and users
|
|
92
|
+
* read it, and this change is about adding the next action, not about rewording the
|
|
93
|
+
* detail that was already right.
|
|
94
|
+
*/
|
|
95
|
+
export function closeDetail(cause) {
|
|
96
|
+
return cause ? ` (${cause.message})` : "";
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=close-cause.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a FAILED CONNECT's errno discriminates, and the next action for each.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 WHY THIS EXISTS — 264 answered the CLOSE family and left the CONNECT site in the
|
|
5
|
+
* census's silent bucket. They are the same shape one step apart: `close-cause.ts` was
|
|
6
|
+
* written because five close handlers read `cause.message` and threw `cause.code` away,
|
|
7
|
+
* and `bridge.ts`'s connect handler does exactly that too. Measured at 265 against real
|
|
8
|
+
* sockets, two families:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* nothing bound the port code=ECONNREFUSED syscall=connect "connect ECONNREFUSED 127.0.0.1:43305"
|
|
12
|
+
* host name does not resolve code=ENOTFOUND syscall=getaddrinfo "getaddrinfo ENOTFOUND not-a-real-host.invalid"
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* and what a caller received for BOTH was one code and one hint:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* [bridge_unavailable] Cannot reach the Godot editor bridge at 127.0.0.1:43305.
|
|
19
|
+
* Is the editor open with the "Breakpoint MCP" plugin enabled? (connect ECONNREFUSED …)
|
|
20
|
+
* [bridge_unavailable] Cannot reach the Godot editor bridge at not-a-real-host.invalid:6010.
|
|
21
|
+
* Is the editor open with the "Breakpoint MCP" plugin enabled? (getaddrinfo ENOTFOUND …)
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* 🔴 AND THE SECOND ONE IS NOT MERELY UNHELPFUL, IT IS WRONG ABOUT WHAT HAPPENED. On
|
|
25
|
+
* ENOTFOUND the name never resolved, so nothing was contacted: no packet left this
|
|
26
|
+
* machine, and whether the editor is open and its plugin enabled had no bearing on the
|
|
27
|
+
* failure. The caller with a typo in `BREAKPOINT_BRIDGE_HOST` is sent to look at Godot.
|
|
28
|
+
* That is 263 §3.2's rule — a capability read is a question for somebody, and there may be
|
|
29
|
+
* nobody — arriving at the transport: the hint answers "why can this peer not serve me",
|
|
30
|
+
* and in the unresolved case there is no peer on the other end of the question.
|
|
31
|
+
*
|
|
32
|
+
* 🔴 THE HINT IS SUPPRESSED RATHER THAN REPLACED. `connectHint` returns `""` for the
|
|
33
|
+
* unresolved family and the instance's own hint for everything else, so the message a
|
|
34
|
+
* caller reads is BYTE-IDENTICAL to what shipped in every case except the one that was
|
|
35
|
+
* making a false claim. The next action goes in the `remedy` FIELD, which is the channel
|
|
36
|
+
* `remedyClause` reads and 264 §1.2 found seven other sites answering around.
|
|
37
|
+
*
|
|
38
|
+
* 🔴 AND WHAT IT DELIBERATELY DOES NOT DO — 264 §3.3's rule, kept. An errno outside the
|
|
39
|
+
* two measured families gets NO remedy and NO hint change: `classifyConnect` returns
|
|
40
|
+
* `"unclassified"` and `connectRemedy` returns `undefined`, with a test asserting it.
|
|
41
|
+
* EHOSTUNREACH, ETIMEDOUT, EACCES and EAI_AGAIN are all plausible here and NONE of them
|
|
42
|
+
* was driven — this container's egress answers an unroutable address with ECONNREFUSED,
|
|
43
|
+
* so the case could not be produced honestly and is therefore not spoken for. A third
|
|
44
|
+
* family inheriting one of these sentences by default would be a claim nobody made
|
|
45
|
+
* wearing the authority of one that was measured.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Errnos that mean the address was reached and the connection was REFUSED.
|
|
49
|
+
*
|
|
50
|
+
* ECONNREFUSED: the host's stack answered with RST — the machine is there, and nothing is
|
|
51
|
+
* listening on that port. It is the ordinary "the editor is not running" case, and it is
|
|
52
|
+
* the one case where the instance hint is exactly right.
|
|
53
|
+
*/
|
|
54
|
+
export const REFUSED_CONNECT_CODES = new Set(["ECONNREFUSED"]);
|
|
55
|
+
/**
|
|
56
|
+
* Errnos that mean the host NAME never became an address, so nothing was contacted.
|
|
57
|
+
*
|
|
58
|
+
* ENOTFOUND: `getaddrinfo` found no record. Note the syscall — this failure happens
|
|
59
|
+
* before any packet is sent, which is why the peer's own state cannot be the cause and
|
|
60
|
+
* the hint that names it has to go.
|
|
61
|
+
*
|
|
62
|
+
* EAI_AGAIN is deliberately ABSENT: it is a TEMPORARY resolver failure, its next action
|
|
63
|
+
* is "retry" rather than "fix the name", and 265 did not drive it.
|
|
64
|
+
*/
|
|
65
|
+
export const UNRESOLVED_HOST_CODES = new Set(["ENOTFOUND"]);
|
|
66
|
+
/**
|
|
67
|
+
* Which of the three a failed connect was.
|
|
68
|
+
*
|
|
69
|
+
* Unlike `classifyClose`, a missing `cause` is NOT a meaningful state here: a connect
|
|
70
|
+
* that fails always fails with an error, and this handler is only ever reached from
|
|
71
|
+
* `socket.once("error")`. An absent cause is therefore `"unclassified"`, not a fourth
|
|
72
|
+
* family invented to fill the slot.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyConnect(cause) {
|
|
75
|
+
if (!cause)
|
|
76
|
+
return "unclassified";
|
|
77
|
+
const code = cause.code;
|
|
78
|
+
if (typeof code !== "string")
|
|
79
|
+
return "unclassified";
|
|
80
|
+
if (REFUSED_CONNECT_CODES.has(code))
|
|
81
|
+
return "refused";
|
|
82
|
+
if (UNRESOLVED_HOST_CODES.has(code))
|
|
83
|
+
return "unresolved";
|
|
84
|
+
return "unclassified";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The hint that belongs in the MESSAGE, given what actually failed.
|
|
88
|
+
*
|
|
89
|
+
* Returns the instance's own hint unchanged for every family except `"unresolved"`,
|
|
90
|
+
* where the hint is a statement about a peer that was never contacted. Suppressing is
|
|
91
|
+
* the whole change: nothing is reworded, so every existing caller and test reading the
|
|
92
|
+
* refused case reads the same bytes.
|
|
93
|
+
*/
|
|
94
|
+
export function connectHint(cause, hint) {
|
|
95
|
+
return classifyConnect(cause) === "unresolved" ? "" : hint;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The next action, given who the peer was and which env var names this client's host —
|
|
99
|
+
* or `undefined` when the errno does not determine one.
|
|
100
|
+
*
|
|
101
|
+
* `hostKnob` is per-INSTANCE for the same reason `deadlineKnob` is (see `BridgeClient`'s
|
|
102
|
+
* constructor): the editor bridge is configured by `BREAKPOINT_BRIDGE_HOST` and the
|
|
103
|
+
* runtime bridge and its peers by `BREAKPOINT_RUNTIME_HOST`, and a sentence naming the
|
|
104
|
+
* wrong one sends the operator to a knob that cannot move the address they just failed
|
|
105
|
+
* to reach.
|
|
106
|
+
*
|
|
107
|
+
* Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
|
|
108
|
+
* head, a full stop at the end, and nothing named that does not resolve.
|
|
109
|
+
*/
|
|
110
|
+
export function connectRemedy(cause, peer, hostKnob) {
|
|
111
|
+
switch (classifyConnect(cause)) {
|
|
112
|
+
case "refused":
|
|
113
|
+
// The errno is NOT repeated: `bridge.ts` already puts `(connect ECONNREFUSED …)` in
|
|
114
|
+
// the sentence immediately to the left, and a second copy teaches nobody anything.
|
|
115
|
+
return (`Start ${peer} and retry — the machine answered and refused the connection, ` +
|
|
116
|
+
`so the address is right and nothing is listening on that port.`);
|
|
117
|
+
case "unresolved":
|
|
118
|
+
return (`Check ${hostKnob} — the host name did not resolve, so nothing was contacted ` +
|
|
119
|
+
`and the state of ${peer} is not what failed here.`);
|
|
120
|
+
default:
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=connect-cause.js.map
|
package/dist/csdap.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { DapError } from "./dap.js";
|
|
3
3
|
import { OverdueLedger } from "./late-reply.js";
|
|
4
|
+
import { closeDetail, closeRemedy } from "./close-cause.js";
|
|
5
|
+
/**
|
|
6
|
+
* Who answers this plane, in the words its late-reply ledger already uses.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
|
|
9
|
+
* late and `closeRemedy` names it when the connection drops. Two literals would let the
|
|
10
|
+
* same peer acquire two names on the same run, which is the class of drift 257's
|
|
11
|
+
* per-instance deadline noun was written to stop.
|
|
12
|
+
*/
|
|
13
|
+
const CSDAP_PEER = "the debug adapter";
|
|
4
14
|
/**
|
|
5
15
|
* How long `start()` waits for the entry `stopped` event when `stopAtEntry` was
|
|
6
16
|
* requested. Bounded so an adapter that ignores stop-on-entry reports `running`
|
|
@@ -41,7 +51,7 @@ export class CsDapClient extends EventEmitter {
|
|
|
41
51
|
* dropped. `seq` is monotonic and is never reset, so a seq is never reused and
|
|
42
52
|
* a late response can only be its own request's.
|
|
43
53
|
*/
|
|
44
|
-
ledger = new OverdueLedger("C# DAP",
|
|
54
|
+
ledger = new OverdueLedger("C# DAP", CSDAP_PEER, "GODOT_CSDAP_TIMEOUT_MS");
|
|
45
55
|
configured = false;
|
|
46
56
|
/** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
|
|
47
57
|
watches = [];
|
|
@@ -117,10 +127,16 @@ export class CsDapClient extends EventEmitter {
|
|
|
117
127
|
this.state = "terminated";
|
|
118
128
|
this.configured = false;
|
|
119
129
|
this.sessionStarted = false;
|
|
120
|
-
const detail =
|
|
130
|
+
const detail = closeDetail(cause);
|
|
131
|
+
// 🔴 THE SAME ERRNO SPLIT `bridge.ts` GOT (264 §3), IN THE MESSAGE BECAUSE THIS CLASS
|
|
132
|
+
// HAS NOWHERE ELSE TO PUT IT. `DapError` carries no `remedy` field, and the plane's
|
|
133
|
+
// `fail()` renders no `remedyClause`, so the next action goes where the caller will
|
|
134
|
+
// actually read it. 264's census records the asymmetry rather than hiding it: of 25
|
|
135
|
+
// host-raised failures about the world, 13 are on classes that cannot carry an answer.
|
|
136
|
+
const remedy = closeRemedy(cause, CSDAP_PEER);
|
|
121
137
|
for (const [, p] of this.pending) {
|
|
122
138
|
clearTimeout(p.timer);
|
|
123
|
-
p.reject(new DapError(p.command, `C# DAP connection closed${detail}`));
|
|
139
|
+
p.reject(new DapError(p.command, `C# DAP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
|
|
124
140
|
}
|
|
125
141
|
this.pending.clear();
|
|
126
142
|
this.emit("closed");
|
package/dist/cslsp.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { LspError } from "./lsp.js";
|
|
3
3
|
import { OverdueLedger } from "./late-reply.js";
|
|
4
|
+
import { closeDetail, closeRemedy } from "./close-cause.js";
|
|
4
5
|
import { packageVersion } from "./version.js";
|
|
6
|
+
/**
|
|
7
|
+
* Who answers this plane, in the words its late-reply ledger already uses.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
|
|
10
|
+
* late and `closeRemedy` names it when the connection drops. Two literals would let the
|
|
11
|
+
* same peer acquire two names on the same run, which is the class of drift 257's
|
|
12
|
+
* per-instance deadline noun was written to stop.
|
|
13
|
+
*/
|
|
14
|
+
const CSLSP_PEER = "the language server";
|
|
5
15
|
/**
|
|
6
16
|
* Minimal LSP client for the C#/.NET semantic plane (D4 C2). Transport-agnostic:
|
|
7
17
|
* it drives any `JsonRpcChannel`, so production spawns OmniSharp over stdio
|
|
@@ -31,7 +41,7 @@ export class CsLspClient {
|
|
|
31
41
|
* dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
|
|
32
42
|
* so an id is never reused and a late reply can only be its own request's.
|
|
33
43
|
*/
|
|
34
|
-
ledger = new OverdueLedger("C# LSP",
|
|
44
|
+
ledger = new OverdueLedger("C# LSP", CSLSP_PEER, "GODOT_CSLSP_TIMEOUT_MS");
|
|
35
45
|
/** Absolute project root path (no trailing slash), used to canonicalize URIs. */
|
|
36
46
|
rootFsPath;
|
|
37
47
|
constructor(channel, rootUri, timeoutMs) {
|
|
@@ -128,9 +138,16 @@ export class CsLspClient {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
onClose(cause) {
|
|
141
|
+
const detail = closeDetail(cause);
|
|
142
|
+
// 🔴 THE SAME ERRNO SPLIT `bridge.ts` GOT (264 §3), IN THE MESSAGE BECAUSE THIS CLASS
|
|
143
|
+
// HAS NOWHERE ELSE TO PUT IT. `LspError` carries no `remedy` field, and the plane's
|
|
144
|
+
// `fail()` renders no `remedyClause`, so the next action goes where the caller will
|
|
145
|
+
// actually read it. 264's census records the asymmetry rather than hiding it: of 25
|
|
146
|
+
// host-raised failures about the world, 13 are on classes that cannot carry an answer.
|
|
147
|
+
const remedy = closeRemedy(cause, CSLSP_PEER);
|
|
131
148
|
for (const [, p] of this.pending) {
|
|
132
149
|
clearTimeout(p.timer);
|
|
133
|
-
p.reject(new LspError("closed", `C# LSP connection closed${
|
|
150
|
+
p.reject(new LspError("closed", `C# LSP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
|
|
134
151
|
}
|
|
135
152
|
this.pending.clear();
|
|
136
153
|
this.initialized = null;
|
package/dist/dap.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { FramedConnection } from "./framing.js";
|
|
3
3
|
import { OverdueLedger } from "./late-reply.js";
|
|
4
|
+
import { closeDetail, closeRemedy } from "./close-cause.js";
|
|
5
|
+
/**
|
|
6
|
+
* Who answers this plane, in the words its late-reply ledger already uses.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
|
|
9
|
+
* late and `closeRemedy` names it when the connection drops. Two literals would let the
|
|
10
|
+
* same peer acquire two names on the same run, which is the class of drift 257's
|
|
11
|
+
* per-instance deadline noun was written to stop.
|
|
12
|
+
*/
|
|
13
|
+
const DAP_PEER = "the debug adapter";
|
|
4
14
|
export class DapError extends Error {
|
|
5
15
|
command;
|
|
6
16
|
constructor(command, message) {
|
|
@@ -73,7 +83,7 @@ export class DapClient extends EventEmitter {
|
|
|
73
83
|
* dropped. `seq` is monotonic and is never reset, so a seq is never reused and
|
|
74
84
|
* a late response can only be its own request's.
|
|
75
85
|
*/
|
|
76
|
-
ledger = new OverdueLedger("DAP",
|
|
86
|
+
ledger = new OverdueLedger("DAP", DAP_PEER, "GODOT_DAP_TIMEOUT_MS");
|
|
77
87
|
configured = false;
|
|
78
88
|
/** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
|
|
79
89
|
watches = [];
|
|
@@ -141,10 +151,16 @@ export class DapClient extends EventEmitter {
|
|
|
141
151
|
// `cause` is the socket-level error when the drop had one (ECONNRESET, EPIPE).
|
|
142
152
|
// Without it every pending request reported a generic close and the operator
|
|
143
153
|
// could not tell a crashed server from something else holding the port.
|
|
144
|
-
const detail =
|
|
154
|
+
const detail = closeDetail(cause);
|
|
155
|
+
// 🔴 THE SAME ERRNO SPLIT `bridge.ts` GOT (264 §3), IN THE MESSAGE BECAUSE THIS CLASS
|
|
156
|
+
// HAS NOWHERE ELSE TO PUT IT. `DapError` carries no `remedy` field, and the plane's
|
|
157
|
+
// `fail()` renders no `remedyClause`, so the next action goes where the caller will
|
|
158
|
+
// actually read it. 264's census records the asymmetry rather than hiding it: of 25
|
|
159
|
+
// host-raised failures about the world, 13 are on classes that cannot carry an answer.
|
|
160
|
+
const remedy = closeRemedy(cause, DAP_PEER);
|
|
145
161
|
for (const [, p] of this.pending) {
|
|
146
162
|
clearTimeout(p.timer);
|
|
147
|
-
p.reject(new DapError(p.command, `DAP connection closed${detail}`));
|
|
163
|
+
p.reject(new DapError(p.command, `DAP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
|
|
148
164
|
}
|
|
149
165
|
this.pending.clear();
|
|
150
166
|
this.emit("closed");
|
package/dist/index.js
CHANGED
|
@@ -30,7 +30,9 @@ async function main() {
|
|
|
30
30
|
// This instance's deadline comes from BREAKPOINT_RUNTIME_TIMEOUT_MS, not the
|
|
31
31
|
// editor bridge's variable — a late reply here must name the knob that can
|
|
32
32
|
// actually move it, and the peer that answered is the running game.
|
|
33
|
-
"BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game"
|
|
33
|
+
"BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game",
|
|
34
|
+
// And its ADDRESS comes from BREAKPOINT_RUNTIME_HOST, for the same reason (265).
|
|
35
|
+
"BREAKPOINT_RUNTIME_HOST");
|
|
34
36
|
const lsp = new LspClient(config.lspHost, config.lspPort, config.projectUri, config.lspTimeoutMs);
|
|
35
37
|
// D4 C2: the C# semantic plane. OmniSharp is spawned over stdio (lazily, on the
|
|
36
38
|
// first cs_* call) against the C# project root — so a host without OmniSharp
|
|
@@ -50,7 +52,7 @@ async function main() {
|
|
|
50
52
|
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
51
53
|
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
52
54
|
// godot://… resources and receive notifications/resources/updated.
|
|
53
|
-
const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.
|
|
55
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.2" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
54
56
|
// 208 §7.1 — delete from the wire what the protocol already says: the SDK's
|
|
55
57
|
// draft-07 `$schema` inside every input/output schema (MCP's default dialect is
|
|
56
58
|
// 2020-12, and declaring another is an explicit switch a peer may reject), and
|
package/dist/lsp.js
CHANGED
|
@@ -2,6 +2,16 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import { FramedConnection } from "./framing.js";
|
|
3
3
|
import { packageVersion } from "./version.js";
|
|
4
4
|
import { OverdueLedger } from "./late-reply.js";
|
|
5
|
+
import { closeDetail, closeRemedy } from "./close-cause.js";
|
|
6
|
+
/**
|
|
7
|
+
* Who answers this plane, in the words its late-reply ledger already uses.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
|
|
10
|
+
* late and `closeRemedy` names it when the connection drops. Two literals would let the
|
|
11
|
+
* same peer acquire two names on the same run, which is the class of drift 257's
|
|
12
|
+
* per-instance deadline noun was written to stop.
|
|
13
|
+
*/
|
|
14
|
+
const LSP_PEER = "the language server";
|
|
5
15
|
export class LspError extends Error {
|
|
6
16
|
code;
|
|
7
17
|
constructor(code, message) {
|
|
@@ -31,7 +41,7 @@ export class LspClient {
|
|
|
31
41
|
* dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
|
|
32
42
|
* so an id is never reused and a late reply can only be its own request's.
|
|
33
43
|
*/
|
|
34
|
-
ledger = new OverdueLedger("LSP",
|
|
44
|
+
ledger = new OverdueLedger("LSP", LSP_PEER, "GODOT_LSP_TIMEOUT_MS");
|
|
35
45
|
/** Absolute project root path (no trailing slash), used to canonicalize URIs. */
|
|
36
46
|
rootFsPath;
|
|
37
47
|
constructor(host, port, rootUri, timeoutMs) {
|
|
@@ -132,10 +142,16 @@ export class LspClient {
|
|
|
132
142
|
// `cause` is the socket-level error when the drop had one (ECONNRESET, EPIPE).
|
|
133
143
|
// Without it every pending request reported a generic close and the operator
|
|
134
144
|
// could not tell a crashed server from something else holding the port.
|
|
135
|
-
const detail =
|
|
145
|
+
const detail = closeDetail(cause);
|
|
146
|
+
// 🔴 THE SAME ERRNO SPLIT `bridge.ts` GOT (264 §3), IN THE MESSAGE BECAUSE THIS CLASS
|
|
147
|
+
// HAS NOWHERE ELSE TO PUT IT. `LspError` carries no `remedy` field, and the plane's
|
|
148
|
+
// `fail()` renders no `remedyClause`, so the next action goes where the caller will
|
|
149
|
+
// actually read it. 264's census records the asymmetry rather than hiding it: of 25
|
|
150
|
+
// host-raised failures about the world, 13 are on classes that cannot carry an answer.
|
|
151
|
+
const remedy = closeRemedy(cause, LSP_PEER);
|
|
136
152
|
for (const [, p] of this.pending) {
|
|
137
153
|
clearTimeout(p.timer);
|
|
138
|
-
p.reject(new LspError("closed", `LSP connection closed${detail}`));
|
|
154
|
+
p.reject(new LspError("closed", `LSP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
|
|
139
155
|
}
|
|
140
156
|
this.pending.clear();
|
|
141
157
|
this.initialized = null;
|
package/dist/peers.js
CHANGED
|
@@ -44,6 +44,35 @@ export class PeerRegistry {
|
|
|
44
44
|
all() {
|
|
45
45
|
return [...this.peers.values()].map((e) => e.info);
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The one `unknown_peer` refusal, raised from both the read path and the write path.
|
|
49
|
+
*
|
|
50
|
+
* 🔴 WHY THIS IS A FUNCTION (265). `clientFor` and `stop` both refused an unknown id
|
|
51
|
+
* with the code `unknown_peer`, off the SAME registry, in two sentences that had drifted:
|
|
52
|
+
*
|
|
53
|
+
* ```
|
|
54
|
+
* clientFor("peer-9") No peer with id "peer-9". Live peers: (none). Spawn peers with runtime_spawn_peers, or omit `peer`…
|
|
55
|
+
* stop("peer-9") No peer with id "peer-9". Live peers: (none).
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* One named a next action and the other stopped dead after the ids, and nothing in this
|
|
59
|
+
* repository could see the difference — 264 §1.2 measured why: a next action written
|
|
60
|
+
* into a message BODY reaches no reader, so `remedyClause()` returned `""` for both and
|
|
61
|
+
* check 28's grammar arm judged neither. The answer moves into the `remedy` FIELD, which
|
|
62
|
+
* is the channel that IS read, and the two paths stop being two sentences.
|
|
63
|
+
*
|
|
64
|
+
* 🔴 AND THE REMEDY IS BRANCHED ON THE REGISTRY, which is the point of 264's "silent,
|
|
65
|
+
* but the host holds something that discriminates" column. Telling a caller with three
|
|
66
|
+
* live peers to spawn some is the wrong next action; telling a caller with none to use
|
|
67
|
+
* one of the ids above names nothing. The registry answers which sentence is true, and
|
|
68
|
+
* it was in hand at both sites the whole time.
|
|
69
|
+
*/
|
|
70
|
+
unknownPeer(id) {
|
|
71
|
+
const ids = this.live().map((p) => p.id);
|
|
72
|
+
return new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`, ids.length
|
|
73
|
+
? `Address one of the live peer ids above, or omit \`peer\` to address the default running game.`
|
|
74
|
+
: `Spawn peers with runtime_spawn_peers, or omit \`peer\` to address the default running game.`);
|
|
75
|
+
}
|
|
47
76
|
/**
|
|
48
77
|
* The bridge client addressing one peer. Throws a BridgeError the runtime
|
|
49
78
|
* tools' `fail()` renders — an unknown or dead peer must read as a clear
|
|
@@ -51,11 +80,8 @@ export class PeerRegistry {
|
|
|
51
80
|
*/
|
|
52
81
|
clientFor(id) {
|
|
53
82
|
const e = this.peers.get(id);
|
|
54
|
-
if (!e)
|
|
55
|
-
|
|
56
|
-
throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}. ` +
|
|
57
|
-
`Spawn peers with runtime_spawn_peers, or omit \`peer\` to address the default running game.`);
|
|
58
|
-
}
|
|
83
|
+
if (!e)
|
|
84
|
+
throw this.unknownPeer(id);
|
|
59
85
|
if (e.stopped)
|
|
60
86
|
throw new BridgeError("peer_stopped", `Peer "${id}" was stopped by runtime_peer_stop.`);
|
|
61
87
|
const m = this.procs.get(e.managedId);
|
|
@@ -149,7 +175,11 @@ export class PeerRegistry {
|
|
|
149
175
|
// The FILE, not resolveBridgeSecret: the addon's _setup_auth() does not
|
|
150
176
|
// read BREAKPOINT_*_SECRET, so a host-launched child authenticates with
|
|
151
177
|
// whatever is on disk regardless of the host's env overrides.
|
|
152
|
-
() => readProjectSecret(this.cfg.projectPath)
|
|
178
|
+
() => readProjectSecret(this.cfg.projectPath),
|
|
179
|
+
// A peer's deadline and peer-noun are its own, and so is its ADDRESS: peers are
|
|
180
|
+
// dialled on `cfg.runtimeHost`, so an unresolved-host remedy here must name
|
|
181
|
+
// BREAKPOINT_RUNTIME_HOST and not the editor bridge's variable (265).
|
|
182
|
+
"BREAKPOINT_RUNTIME_TIMEOUT_MS", `peer ${id}`, "BREAKPOINT_RUNTIME_HOST");
|
|
153
183
|
const entry = {
|
|
154
184
|
info: { id, port, pid: managed.child.pid ?? null, role: opts.role ?? null, ready: false },
|
|
155
185
|
client,
|
|
@@ -184,10 +214,8 @@ export class PeerRegistry {
|
|
|
184
214
|
/** Stop one peer (or all). Repeating is a no-op, so the tool is idempotent. */
|
|
185
215
|
stop(id, all = false) {
|
|
186
216
|
const targets = all ? [...this.peers.keys()] : id ? [id] : [];
|
|
187
|
-
if (!all && id && !this.peers.has(id))
|
|
188
|
-
|
|
189
|
-
throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`);
|
|
190
|
-
}
|
|
217
|
+
if (!all && id && !this.peers.has(id))
|
|
218
|
+
throw this.unknownPeer(id);
|
|
191
219
|
const stopped = [];
|
|
192
220
|
for (const t of targets) {
|
|
193
221
|
const e = this.peers.get(t);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.78.
|
|
3
|
+
"version": "1.78.2",
|
|
4
4
|
"description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|