@johnhenry/wsh 0.16.0 → 0.16.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/session.mjs +59 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/session.mjs CHANGED
@@ -20,6 +20,17 @@ const STATE_OPENING = 'opening';
20
20
  const STATE_ACTIVE = 'active';
21
21
  const STATE_CLOSED = 'closed';
22
22
 
23
+ /**
24
+ * Stream-mode data and control streams are independently-multiplexed
25
+ * transport streams (QUIC/QMux) with no guaranteed relative delivery
26
+ * order (wsh #24). When the data stream reaches EOF, the server-sent
27
+ * `CLOSE` control message -- not the data-stream FIN -- is the
28
+ * authoritative close signal, so `_pumpDataStream()` waits up to this
29
+ * long for `CLOSE` to arrive before falling back to closing on data-EOF
30
+ * alone (a safety net for servers that never send `CLOSE`).
31
+ */
32
+ const DATA_EOF_CLOSE_GRACE_MS = 300;
33
+
23
34
  // ── Text encoding ─────────────────────────────────────────────────────
24
35
 
25
36
  const textEncoder = new TextEncoder();
@@ -133,6 +144,16 @@ export class WshSession {
133
144
  /** @type {number|null} Exit code received from the server. */
134
145
  #exitCode = null;
135
146
 
147
+ /**
148
+ * Pending fallback timer scheduled when the data stream hits EOF
149
+ * before a `CLOSE` control message has arrived (wsh #24). Cleared
150
+ * as soon as `CLOSE` arrives (or the session is otherwise closed) so
151
+ * a prompt `CLOSE` short-circuits the wait instead of idling out the
152
+ * full grace period.
153
+ * @type {ReturnType<typeof setTimeout>|null}
154
+ */
155
+ #closeGraceTimer = null;
156
+
136
157
  // ── Callbacks ───────────────────────────────────────────────────────
137
158
 
138
159
  /**
@@ -580,6 +601,7 @@ export class WshSession {
580
601
  // Optimistically mark closed so no further writes are accepted.
581
602
  this.#state = STATE_CLOSED;
582
603
  this.#abort.abort();
604
+ this.#clearCloseGraceTimer();
583
605
 
584
606
  // Send CLOSE to the server (best-effort).
585
607
  try {
@@ -697,7 +719,11 @@ export class WshSession {
697
719
  }
698
720
 
699
721
  case MSG.CLOSE: {
700
- // Server-initiated close.
722
+ // Server-initiated close. This is the authoritative close signal
723
+ // for stream-mode sessions (wsh #24) -- it always short-circuits
724
+ // any pending data-EOF grace timer, regardless of which arrived
725
+ // first.
726
+ this.#clearCloseGraceTimer();
701
727
  if (this.#state !== STATE_CLOSED) {
702
728
  this.#state = STATE_CLOSED;
703
729
  this.#abort.abort();
@@ -879,17 +905,43 @@ export class WshSession {
879
905
  reader.releaseLock();
880
906
  }
881
907
 
882
- // If the data stream ended but we haven't received a CLOSE message,
883
- // transition to closed state.
884
- if (this.#state !== STATE_CLOSED) {
885
- this.#state = STATE_CLOSED;
886
- this.#releaseStreams();
887
- this.#emitClose();
908
+ // The data stream ended, but its FIN carries no ordering guarantee
909
+ // relative to the control stream (wsh #24): a server that closes the
910
+ // data stream and sends EXIT/CLOSE around the same time can have
911
+ // this EOF arrive before those control messages. Closing the session
912
+ // here unconditionally would resolve onClose without onExit ever
913
+ // having fired. Instead, give the (expected) CLOSE control message a
914
+ // bounded grace period to arrive -- its handler clears this timer and
915
+ // performs the real transition -- and only self-close on timeout as a
916
+ // fallback for servers that never send CLOSE after ending the data
917
+ // stream.
918
+ if (this.#state !== STATE_CLOSED && this.#closeGraceTimer === null) {
919
+ this.#closeGraceTimer = setTimeout(() => {
920
+ this.#closeGraceTimer = null;
921
+ if (this.#state !== STATE_CLOSED) {
922
+ this.#state = STATE_CLOSED;
923
+ this.#releaseStreams();
924
+ this.#emitClose();
925
+ }
926
+ }, DATA_EOF_CLOSE_GRACE_MS);
927
+ this.#closeGraceTimer?.unref?.();
888
928
  }
889
929
  }
890
930
 
891
931
  // ── Private helpers ─────────────────────────────────────────────────
892
932
 
933
+ /**
934
+ * Cancel any pending data-EOF close-grace timer (wsh #24). Safe to call
935
+ * whether or not a timer is currently scheduled.
936
+ * @private
937
+ */
938
+ #clearCloseGraceTimer() {
939
+ if (this.#closeGraceTimer !== null) {
940
+ clearTimeout(this.#closeGraceTimer);
941
+ this.#closeGraceTimer = null;
942
+ }
943
+ }
944
+
893
945
  /**
894
946
  * Release stream resources without sending a CLOSE message.
895
947
  * @private