@johnhenry/wsh 0.16.1 → 0.17.1
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/README.md +349 -28
- package/package.json +16 -3
- package/src/auth.mjs +63 -1
- package/src/client.mjs +379 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +47 -192
- package/src/index.d.ts +261 -8
- package/src/index.mjs +10 -2
- package/src/known-hosts.mjs +139 -0
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +30 -16
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +73 -21
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
package/src/mcp-bridge.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { MSG, mcpDiscover, mcpCall } from './messages.mjs';
|
|
10
|
+
import { waitForControlMessage } from './control-listener.mjs';
|
|
11
|
+
import { MCP_CALL_ID_FEATURE } from './client.mjs';
|
|
10
12
|
|
|
11
13
|
/** Default timeout for MCP operations (15 seconds). */
|
|
12
14
|
const MCP_TIMEOUT_MS = 15_000;
|
|
@@ -18,14 +20,35 @@ export class WshMcpBridge {
|
|
|
18
20
|
/** @type {Map<string, object>} Cached tool specs: name -> { name, description, parameters } */
|
|
19
21
|
#tools = new Map();
|
|
20
22
|
|
|
23
|
+
/** @type {number} Monotonically increasing counter for call_id. */
|
|
24
|
+
#callCounter = 0;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tail of the serialised call chain, used only against servers that do not
|
|
28
|
+
* advertise `mcp-call-id`. Never rejects.
|
|
29
|
+
* @type {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
#queue = Promise.resolve();
|
|
32
|
+
|
|
21
33
|
/**
|
|
22
|
-
* @param {object} client - A WshClient or
|
|
34
|
+
* @param {object} client - A `WshClient`, a `WshTransport`, or any object
|
|
35
|
+
* exposing:
|
|
23
36
|
* - sendControl(msg): send a control message
|
|
24
37
|
* - addControlListener(fn) / removeControlListener(fn): message listeners
|
|
25
|
-
* (or _controlListeners array, or onControl callback)
|
|
38
|
+
* (or a `_controlListeners` array, or a settable `onControl` callback)
|
|
39
|
+
*
|
|
40
|
+
* `sendControl` is checked here rather than at first use: without it
|
|
41
|
+
* `discover()` failed with `TypeError: this.#client.sendControl is not
|
|
42
|
+
* a function` from inside a promise, naming a private field and not
|
|
43
|
+
* the argument that was actually wrong.
|
|
26
44
|
*/
|
|
27
45
|
constructor(client) {
|
|
28
46
|
if (!client) throw new Error('WshMcpBridge requires a client');
|
|
47
|
+
if (typeof client.sendControl !== 'function') {
|
|
48
|
+
throw new TypeError(
|
|
49
|
+
'WshMcpBridge requires a client exposing sendControl() — pass a WshClient or a WshTransport'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
29
52
|
this.#client = client;
|
|
30
53
|
}
|
|
31
54
|
|
|
@@ -73,6 +96,13 @@ export class WshMcpBridge {
|
|
|
73
96
|
*
|
|
74
97
|
* Sends an MCP_CALL message and waits for the MCP_RESULT response.
|
|
75
98
|
*
|
|
99
|
+
* Safe to call concurrently. Against a server advertising `mcp-call-id`
|
|
100
|
+
* each call carries a correlation id and takes only its own reply;
|
|
101
|
+
* otherwise calls are queued one at a time, because without an id on the
|
|
102
|
+
* wire the replies are indistinguishable and `{ success, output }`
|
|
103
|
+
* normalisation strips whatever the payload might have identified itself
|
|
104
|
+
* by.
|
|
105
|
+
*
|
|
76
106
|
* @param {string} toolName - Name of the tool to invoke
|
|
77
107
|
* @param {object} [args={}] - Arguments to pass to the tool
|
|
78
108
|
* @param {object} [opts]
|
|
@@ -91,10 +121,40 @@ export class WshMcpBridge {
|
|
|
91
121
|
);
|
|
92
122
|
}
|
|
93
123
|
|
|
94
|
-
|
|
124
|
+
if (this.#correlates()) return this.#callOnce(toolName, args, timeout);
|
|
125
|
+
|
|
126
|
+
const run = this.#queue.then(
|
|
127
|
+
() => this.#callOnce(toolName, args, timeout),
|
|
128
|
+
() => this.#callOnce(toolName, args, timeout),
|
|
129
|
+
);
|
|
130
|
+
this.#queue = run.then(() => undefined, () => undefined);
|
|
131
|
+
return run;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when the connected server echoes call_id. @private */
|
|
135
|
+
#correlates() {
|
|
136
|
+
return this.#client?.hasFeature?.(MCP_CALL_ID_FEATURE) === true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @private */
|
|
140
|
+
async #callOnce(toolName, args, timeout) {
|
|
141
|
+
const callId = this.#correlates()
|
|
142
|
+
? `bridge-${++this.#callCounter}-${Date.now()}`
|
|
143
|
+
: undefined;
|
|
144
|
+
|
|
145
|
+
await this.#client.sendControl(
|
|
146
|
+
mcpCall({ tool: toolName, arguments: args, callId })
|
|
147
|
+
);
|
|
95
148
|
|
|
96
149
|
const response = await this._waitForMessage(
|
|
97
|
-
(msg) =>
|
|
150
|
+
(msg) => {
|
|
151
|
+
// ERROR carries no call_id and cannot be attributed to one call;
|
|
152
|
+
// it is taken by whichever call is waiting.
|
|
153
|
+
if (msg.type === MSG.ERROR) return true;
|
|
154
|
+
if (msg.type !== MSG.MCP_RESULT) return false;
|
|
155
|
+
if (callId === undefined) return true;
|
|
156
|
+
return msg.call_id === undefined || msg.call_id === callId;
|
|
157
|
+
},
|
|
98
158
|
timeout
|
|
99
159
|
);
|
|
100
160
|
|
|
@@ -171,58 +231,17 @@ export class WshMcpBridge {
|
|
|
171
231
|
/**
|
|
172
232
|
* Wait for a control message matching a predicate.
|
|
173
233
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
234
|
+
* Subscribes to the client's control message flow and resolves when a
|
|
235
|
+
* matching message arrives (or rejects on timeout). Deregistration is
|
|
236
|
+
* `control-listener.mjs`'s job — the version inlined here leaked one
|
|
237
|
+
* permanent `onControl` wrapper per operation against any client whose
|
|
238
|
+
* only hook is that property.
|
|
176
239
|
*
|
|
177
240
|
* @param {function(object): boolean} predicate
|
|
178
241
|
* @param {number} timeoutMs
|
|
179
242
|
* @returns {Promise<object>}
|
|
180
243
|
*/
|
|
181
244
|
_waitForMessage(predicate, timeoutMs) {
|
|
182
|
-
return
|
|
183
|
-
let timer = null;
|
|
184
|
-
let settled = false;
|
|
185
|
-
|
|
186
|
-
const cleanup = () => {
|
|
187
|
-
settled = true;
|
|
188
|
-
if (timer !== null) clearTimeout(timer);
|
|
189
|
-
// Remove listener
|
|
190
|
-
if (this.#client.removeControlListener) {
|
|
191
|
-
this.#client.removeControlListener(listener);
|
|
192
|
-
} else if (this.#client._controlListeners) {
|
|
193
|
-
const idx = this.#client._controlListeners.indexOf(listener);
|
|
194
|
-
if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const listener = (msg) => {
|
|
199
|
-
if (settled) return;
|
|
200
|
-
if (predicate(msg)) {
|
|
201
|
-
cleanup();
|
|
202
|
-
resolve(msg);
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// Register listener on the client
|
|
207
|
-
if (this.#client.addControlListener) {
|
|
208
|
-
this.#client.addControlListener(listener);
|
|
209
|
-
} else if (this.#client._controlListeners) {
|
|
210
|
-
this.#client._controlListeners.push(listener);
|
|
211
|
-
} else {
|
|
212
|
-
// Fallback: wrap existing onControl
|
|
213
|
-
const prev = this.#client.onControl;
|
|
214
|
-
this.#client.onControl = (msg) => {
|
|
215
|
-
prev?.(msg);
|
|
216
|
-
listener(msg);
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
timer = setTimeout(() => {
|
|
221
|
-
if (!settled) {
|
|
222
|
-
cleanup();
|
|
223
|
-
reject(new Error(`MCP operation timed out after ${timeoutMs}ms`));
|
|
224
|
-
}
|
|
225
|
-
}, timeoutMs);
|
|
226
|
-
});
|
|
245
|
+
return waitForControlMessage(this.#client, predicate, timeoutMs, 'MCP operation');
|
|
227
246
|
}
|
|
228
247
|
}
|
package/src/messages.gen.mjs
CHANGED
|
@@ -145,6 +145,10 @@ export const MSG = Object.freeze({
|
|
|
145
145
|
|
|
146
146
|
// Terminal
|
|
147
147
|
TERMINAL_CONFIG: 0x9e,
|
|
148
|
+
|
|
149
|
+
// Keys
|
|
150
|
+
AUTHORIZED_KEY_ADD: 0x9f,
|
|
151
|
+
AUTHORIZED_KEY_RESULT: 0xa0,
|
|
148
152
|
});
|
|
149
153
|
|
|
150
154
|
// Reverse lookup: number → name
|
|
@@ -187,13 +191,10 @@ export function hello({ username, features = [], authMethod = AUTH_METHOD.PUBKEY
|
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
193
|
|
|
190
|
-
export function serverHello({ sessionId, features = [], fingerprints = [] } = {}) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
features,
|
|
195
|
-
fingerprints,
|
|
196
|
-
};
|
|
194
|
+
export function serverHello({ sessionId, features = [], fingerprints = [], hostFingerprint } = {}) {
|
|
195
|
+
const msg = { type: MSG.SERVER_HELLO, session_id: sessionId, features, fingerprints };
|
|
196
|
+
if (hostFingerprint !== undefined) msg.host_fingerprint = hostFingerprint;
|
|
197
|
+
return msg;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
export function challenge({ nonce, sessionId } = {}) {
|
|
@@ -445,15 +446,16 @@ export function mcpTools({ tools } = {}) {
|
|
|
445
446
|
};
|
|
446
447
|
}
|
|
447
448
|
|
|
448
|
-
export function mcpCall({ tool, arguments: args } = {}) {
|
|
449
|
-
|
|
449
|
+
export function mcpCall({ tool, arguments: args, callId } = {}) {
|
|
450
|
+
const m = { type: MSG.MCP_CALL, tool, arguments: args };
|
|
451
|
+
if (callId !== undefined) m.call_id = callId;
|
|
452
|
+
return m;
|
|
450
453
|
}
|
|
451
454
|
|
|
452
|
-
export function mcpResult({ result } = {}) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
};
|
|
455
|
+
export function mcpResult({ result, callId } = {}) {
|
|
456
|
+
const msg = { type: MSG.MCP_RESULT, result };
|
|
457
|
+
if (callId !== undefined) msg.call_id = callId;
|
|
458
|
+
return msg;
|
|
457
459
|
}
|
|
458
460
|
|
|
459
461
|
export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {
|
|
@@ -863,8 +865,8 @@ export function fileOp({ channelId, op, path, offset, length } = {}) {
|
|
|
863
865
|
return msg;
|
|
864
866
|
}
|
|
865
867
|
|
|
866
|
-
export function fileResult({ channelId, success, metadata = {}, errorMessage } = {}) {
|
|
867
|
-
const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata };
|
|
868
|
+
export function fileResult({ channelId, success, metadata = {}, entries = [], errorMessage } = {}) {
|
|
869
|
+
const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata, entries };
|
|
868
870
|
if (errorMessage !== undefined) msg.error_message = errorMessage;
|
|
869
871
|
return msg;
|
|
870
872
|
}
|
|
@@ -914,6 +916,18 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
|
|
|
914
916
|
};
|
|
915
917
|
}
|
|
916
918
|
|
|
919
|
+
export function authorizedKeyAdd({ publicKey, comment } = {}) {
|
|
920
|
+
const msg = { type: MSG.AUTHORIZED_KEY_ADD, public_key: publicKey };
|
|
921
|
+
if (comment !== undefined) msg.comment = comment;
|
|
922
|
+
return msg;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export function authorizedKeyResult({ success, added = false, errorMessage } = {}) {
|
|
926
|
+
const msg = { type: MSG.AUTHORIZED_KEY_RESULT, success, added };
|
|
927
|
+
if (errorMessage !== undefined) msg.error_message = errorMessage;
|
|
928
|
+
return msg;
|
|
929
|
+
}
|
|
930
|
+
|
|
917
931
|
// ── Relay forwarding ─────────────────────────────────────────────────
|
|
918
932
|
|
|
919
933
|
// Message types a relay may forward inside a RelayForward wrapper.
|
package/src/mlkem.mjs
CHANGED
|
@@ -66,7 +66,34 @@ function bytesEqual(a, b) {
|
|
|
66
66
|
return diff === 0;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Which implementation serves ML-KEM-768, with an override for testing.
|
|
71
|
+
*
|
|
72
|
+
* The noble branch is the ONLY path a browser can take -- no browser
|
|
73
|
+
* implements the experimental WebCrypto ML-KEM draft -- and until this
|
|
74
|
+
* override existed no test had ever executed a line of it. `getBackend()`
|
|
75
|
+
* memoised a probe that always succeeds on the repo's target runtime, so
|
|
76
|
+
* every assertion ran against native, and every noble call
|
|
77
|
+
* (`ml_kem768.keygen`, `.encapsulate`, `.decapsulate`) was untouched. A
|
|
78
|
+
* change in noble's argument order or seed semantics -- it is pinned only to
|
|
79
|
+
* `^0.7.0` and called through three positional APIs -- would break hybrid PQ
|
|
80
|
+
* key exchange for every browser consumer while this repo stayed green.
|
|
81
|
+
*
|
|
82
|
+
* `WSH_MLKEM_BACKEND=noble` forces it. Read defensively: this module runs in
|
|
83
|
+
* browsers, where `process` does not exist.
|
|
84
|
+
*/
|
|
85
|
+
function backendOverride() {
|
|
86
|
+
try {
|
|
87
|
+
const value = globalThis.process?.env?.WSH_MLKEM_BACKEND;
|
|
88
|
+
return value === 'noble' || value === 'native' ? value : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
async function getBackend() {
|
|
95
|
+
const forced = backendOverride();
|
|
96
|
+
if (forced) return forced;
|
|
70
97
|
if (!backendPromise) {
|
|
71
98
|
backendPromise = probeNativeSupport().then((supported) => (supported ? 'native' : 'noble'));
|
|
72
99
|
}
|
package/src/session.mjs
CHANGED
|
@@ -153,6 +153,8 @@ export class WshSession {
|
|
|
153
153
|
* @type {ReturnType<typeof setTimeout>|null}
|
|
154
154
|
*/
|
|
155
155
|
#closeGraceTimer = null;
|
|
156
|
+
/** Why the session closed, when it closed for a reason worth naming. */
|
|
157
|
+
#closeReason = null;
|
|
156
158
|
|
|
157
159
|
// ── Callbacks ───────────────────────────────────────────────────────
|
|
158
160
|
|
|
@@ -475,7 +477,7 @@ export class WshSession {
|
|
|
475
477
|
for (const { nonce, ciphertext } of wireChunks) {
|
|
476
478
|
const counter = this.#e2eRecvCounter++;
|
|
477
479
|
try {
|
|
478
|
-
const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext });
|
|
480
|
+
const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext, expectedRoleTag: this.#e2eRecvRoleTag });
|
|
479
481
|
plaintexts.push(plaintext);
|
|
480
482
|
} catch (err) {
|
|
481
483
|
console.error(
|
|
@@ -682,7 +684,7 @@ export class WshSession {
|
|
|
682
684
|
// delivery of *this* frame's plaintext to onData is deferred by
|
|
683
685
|
// one microtask/macrotask relative to synchronous cases.
|
|
684
686
|
const counter = this.#e2eRecvCounter++;
|
|
685
|
-
openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext })
|
|
687
|
+
openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext, expectedRoleTag: this.#e2eRecvRoleTag })
|
|
686
688
|
.then((plaintext) => {
|
|
687
689
|
this.#virtualBackend?.pushData(plaintext);
|
|
688
690
|
try {
|
|
@@ -724,14 +726,7 @@ export class WshSession {
|
|
|
724
726
|
// any pending data-EOF grace timer, regardless of which arrived
|
|
725
727
|
// first.
|
|
726
728
|
this.#clearCloseGraceTimer();
|
|
727
|
-
|
|
728
|
-
this.#state = STATE_CLOSED;
|
|
729
|
-
this.#abort.abort();
|
|
730
|
-
this.#virtualBackend?.close();
|
|
731
|
-
this.#releaseStreams();
|
|
732
|
-
this.#resolvePendingFileChunk(null);
|
|
733
|
-
this.#emitClose();
|
|
734
|
-
}
|
|
729
|
+
this.#closeNow();
|
|
735
730
|
break;
|
|
736
731
|
}
|
|
737
732
|
|
|
@@ -874,11 +869,32 @@ export class WshSession {
|
|
|
874
869
|
if (this.#e2eKey !== null && this.#streamAccumulator) {
|
|
875
870
|
const deliverable = await this.#openStreamChunks(value);
|
|
876
871
|
if (deliverable === null) {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
872
|
+
/*
|
|
873
|
+
* Authentication failure (or corrupt framing) -- the strict
|
|
874
|
+
* "no skip-and-continue" philosophy of virtual-mode's counter
|
|
875
|
+
* check: tear the session down rather than deliver anything
|
|
876
|
+
* from this point on.
|
|
877
|
+
*
|
|
878
|
+
* `break` alone did not tear anything down. It left the loop,
|
|
879
|
+
* and control fell through to the data-EOF grace timer, so the
|
|
880
|
+
* session stayed `active` and writable for the full 300ms and
|
|
881
|
+
* then closed as an ordinary EOF: onClose with no error,
|
|
882
|
+
* onExit never fired, exitCode still null. An application could
|
|
883
|
+
* not tell "the peer forged data at me" from "the process
|
|
884
|
+
* exited and the server forgot to send CLOSE" -- the two were
|
|
885
|
+
* byte-identical to observe.
|
|
886
|
+
*
|
|
887
|
+
* Closed immediately, and the reason recorded so the two are
|
|
888
|
+
* distinguishable. `StreamAuthenticationError` was already
|
|
889
|
+
* built here and handed only to console.error; it is now the
|
|
890
|
+
* close reason as well.
|
|
891
|
+
*/
|
|
892
|
+
this.#closeReason = new StreamAuthenticationError(
|
|
893
|
+
'wsh: stream authentication failed -- session torn down'
|
|
894
|
+
);
|
|
895
|
+
this.#clearCloseGraceTimer();
|
|
896
|
+
this.#closeNow();
|
|
897
|
+
return;
|
|
882
898
|
}
|
|
883
899
|
for (const plaintext of deliverable) {
|
|
884
900
|
try {
|
|
@@ -918,11 +934,7 @@ export class WshSession {
|
|
|
918
934
|
if (this.#state !== STATE_CLOSED && this.#closeGraceTimer === null) {
|
|
919
935
|
this.#closeGraceTimer = setTimeout(() => {
|
|
920
936
|
this.#closeGraceTimer = null;
|
|
921
|
-
|
|
922
|
-
this.#state = STATE_CLOSED;
|
|
923
|
-
this.#releaseStreams();
|
|
924
|
-
this.#emitClose();
|
|
925
|
-
}
|
|
937
|
+
this.#closeNow();
|
|
926
938
|
}, DATA_EOF_CLOSE_GRACE_MS);
|
|
927
939
|
this.#closeGraceTimer?.unref?.();
|
|
928
940
|
}
|
|
@@ -935,6 +947,36 @@ export class WshSession {
|
|
|
935
947
|
* whether or not a timer is currently scheduled.
|
|
936
948
|
* @private
|
|
937
949
|
*/
|
|
950
|
+
/**
|
|
951
|
+
* Close the session once, releasing everything a close has to release.
|
|
952
|
+
*
|
|
953
|
+
* There were two ways a stream-mode session reached `closed` and they did
|
|
954
|
+
* not agree. The CLOSE control handler aborted, closed the virtual backend,
|
|
955
|
+
* released the streams AND settled any parked file chunk. The data-EOF
|
|
956
|
+
* grace fallback -- the path taken when a server ends the data stream
|
|
957
|
+
* without sending CLOSE, which the comment above it says is real -- only
|
|
958
|
+
* released the streams and fired onClose.
|
|
959
|
+
*
|
|
960
|
+
* So `download()`, parked on `_readFileChunk()`, was never settled on that
|
|
961
|
+
* path. It documents returning `null` for a truncated transfer and
|
|
962
|
+
* `download()` turns that into a thrown error; via the fallback it returned
|
|
963
|
+
* nothing at all and the download hung forever, while `onClose` had already
|
|
964
|
+
* fired and the session read `closed`.
|
|
965
|
+
*
|
|
966
|
+
* One method rather than two call sites kept in step by hand: the drift is
|
|
967
|
+
* the bug, and the next field added to a close would have drifted the same
|
|
968
|
+
* way.
|
|
969
|
+
*/
|
|
970
|
+
#closeNow() {
|
|
971
|
+
if (this.#state === STATE_CLOSED) return;
|
|
972
|
+
this.#state = STATE_CLOSED;
|
|
973
|
+
this.#abort.abort();
|
|
974
|
+
this.#virtualBackend?.close();
|
|
975
|
+
this.#releaseStreams();
|
|
976
|
+
this.#resolvePendingFileChunk(null);
|
|
977
|
+
this.#emitClose();
|
|
978
|
+
}
|
|
979
|
+
|
|
938
980
|
#clearCloseGraceTimer() {
|
|
939
981
|
if (this.#closeGraceTimer !== null) {
|
|
940
982
|
clearTimeout(this.#closeGraceTimer);
|
|
@@ -961,9 +1003,19 @@ export class WshSession {
|
|
|
961
1003
|
* Emit the onClose callback exactly once.
|
|
962
1004
|
* @private
|
|
963
1005
|
*/
|
|
1006
|
+
/**
|
|
1007
|
+
* Why the session closed, or null for an ordinary close.
|
|
1008
|
+
*
|
|
1009
|
+
* A torn-down session and a clean exit used to be indistinguishable from
|
|
1010
|
+
* the outside; this is what tells them apart.
|
|
1011
|
+
*/
|
|
1012
|
+
get closeReason() {
|
|
1013
|
+
return this.#closeReason;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
964
1016
|
#emitClose() {
|
|
965
1017
|
try {
|
|
966
|
-
this.onClose?.();
|
|
1018
|
+
this.onClose?.(this.#closeReason);
|
|
967
1019
|
} catch (err) {
|
|
968
1020
|
console.error('[wsh:session] onClose handler error:', err);
|
|
969
1021
|
}
|
package/src/transport-ws.mjs
CHANGED
|
@@ -214,8 +214,22 @@ export class WebSocketTransport extends WshTransport {
|
|
|
214
214
|
// own stream objects -- without this, any reader still waiting on a
|
|
215
215
|
// stream's readable side would hang forever, since nothing else
|
|
216
216
|
// errors or closes it once the peer can no longer respond.
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
//
|
|
218
|
+
// Each step is guarded separately. The courtesy CONNECTION_CLOSE is
|
|
219
|
+
// the one that can realistically throw -- it writes to the socket,
|
|
220
|
+
// and a socket that is already broken is the single most likely
|
|
221
|
+
// reason we are closing at all. Letting that throw would skip both
|
|
222
|
+
// the local `destroy()` (hanging every waiting reader, the exact
|
|
223
|
+
// thing above) and the socket close (leaking the connection), which
|
|
224
|
+
// makes the failure mode strictly worse than the failure that
|
|
225
|
+
// triggered it.
|
|
226
|
+
try {
|
|
227
|
+
this.#qmux?.close();
|
|
228
|
+
} catch { /* peer is unreachable; the local teardown below still matters */ }
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
this.#qmux?.destroy(new Error('Transport closed'));
|
|
232
|
+
} catch { /* a stream's error callback threw; keep tearing down */ }
|
|
219
233
|
|
|
220
234
|
if (this.#ws) {
|
|
221
235
|
try {
|