@forgecart/sdk 1.202610241216.0 → 1.202610241557.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.
@@ -173,6 +173,11 @@ export declare class BaseGraphQLClient {
173
173
  * sink callback, never the operation's lifetime, so a connected stream runs
174
174
  * for as long as the server streams it. Both bounds raise the same typed
175
175
  * code, so one consumer branch answers for both.
176
+ *
177
+ * Disposing the client ends the operation with WS_CLIENT_DISPOSED while it
178
+ * still waits on a handshake (#2102) or, as a query or a mutation, on its
179
+ * answer (#1572): graphql-ws would leave the first waiting forever and
180
+ * COMPLETE the second with no value — see settlePendingOperations.
176
181
  */
177
182
  private executeWsOperation;
178
183
  /**
@@ -212,6 +217,16 @@ export declare class BaseGraphQLClient {
212
217
  * is specific to the bound that raised it, such as #2102's endpoint.
213
218
  */
214
219
  private wsTimeoutError;
220
+ /**
221
+ * The ONE error a disposed client raises (#1994, #2102, #1572): for an
222
+ * operation started after dispose(), for one still waiting on a handshake,
223
+ * and for a query or a mutation still waiting on its answer. Named, so a
224
+ * consumer's error path tells an operation its own client cut off from one
225
+ * the server failed; carrying the endpoint, so a late one stays attributable.
226
+ * Never routed through the error handler: the disposal is the caller's own
227
+ * act, not a fault worth a notification.
228
+ */
229
+ private clientDisposedError;
215
230
  /**
216
231
  * Uniform error contract for the sink-level error path. graphql-ws
217
232
  * delivers a server ErrorMessage payload as a raw GraphQLError[] —
@@ -252,27 +267,35 @@ export declare class BaseGraphQLClient {
252
267
  private settleWsDisposal;
253
268
  private describeFailure;
254
269
  /**
255
- * Ends every operation still waiting on a handshake this socket never
256
- * answered, where its owner stands (#2102). graphql-ws speaks to the sinks
257
- * of operations its socket CARRIED; an unanswered handshake carried none, so
258
- * its disposal says nothing to them and they wait forever on a client that
259
- * will never connect again — and the caller awaiting one waits with it
260
- * (measured from the far end as backends left idle in transaction on a wait
261
- * the client had already abandoned). Erroring from the BOUND is the thing
262
- * this must not do: that fires 10 s later, in whatever context is alive
263
- * then. dispose() is the owner's own stack, so a rejection lands there.
270
+ * Ends, where its owner stands, every operation graphql-ws's disposal would
271
+ * leave without an ending it can use, with WS_CLIENT_DISPOSED. graphql-ws
272
+ * speaks to the sinks of operations its socket CARRIED. An unanswered
273
+ * handshake carried none, so its disposal says nothing to them and they wait
274
+ * forever on a client that will never connect again — and the caller
275
+ * awaiting one waits with it (#2102; measured from the far end as backends
276
+ * left idle in transaction on a wait the client had already abandoned). A
277
+ * query or a mutation it carried unanswered it COMPLETES with no value,
278
+ * which no consumer can tell from a request never made (#1572). Erroring
279
+ * from the BOUND is the thing this must not do: that fires 10 s later, in
280
+ * whatever context is alive then. dispose() is the owner's own stack, so a
281
+ * rejection lands there. A subscription past its handshake is not here: a
282
+ * stream its client ended completes truthfully, and an operation whose
283
+ * answer arrived left the registry with it.
264
284
  *
265
285
  * The socket's owner ends every operation on it, forks included — the same
266
286
  * reason the socket rather than the client carries the disposed flag. A fork
267
287
  * owns no socket and ends only what it started, leaving its owner on the
268
288
  * wire. Idempotent: an entry settles once and deregisters itself doing so.
269
289
  */
270
- private settlePendingHandshakes;
290
+ private settlePendingOperations;
271
291
  /**
272
292
  * Terminal (#1994). Ends the socket, ends any retry in flight, ends every
273
- * operation still waiting on a handshake (#2102), and refuses every later
274
- * operation with WS_CLIENT_DISPOSED — a disposed client never mints another
275
- * socket. Idempotent and synchronous; never throws.
293
+ * operation still waiting on a handshake (#2102) and every query or
294
+ * mutation still waiting on its answer (#1572) with WS_CLIENT_DISPOSED, and
295
+ * refuses every later operation with the same error — a disposed client
296
+ * never mints another socket. An answer that already arrived is kept, and a
297
+ * subscription the socket carried completes. Idempotent and synchronous;
298
+ * never throws.
276
299
  */
277
300
  dispose(): void;
278
301
  fork(transactionToken: string): BaseGraphQLClient;
@@ -116,8 +116,9 @@ const WS_FIRST_VALUE_BUDGET_MS_BY_OPERATION = {
116
116
  // `disposed` is that socket's terminal end, and it lives here rather than
117
117
  // on the client because every client still holding the socket — the forks
118
118
  // included — is off the wire the moment its owner ends it. `pending` is
119
- // socket-level for exactly that reason too (#2102): a handshake belongs to
120
- // the socket, so ending the socket must end everything still waiting on it.
119
+ // socket-level for exactly that reason too (#2102, #1572): an operation
120
+ // waiting on the wire belongs to the socket, so ending the socket must end
121
+ // everything still waiting on it.
121
122
  const WS_CONNECTION_STATE = new WeakMap();
122
123
  class BaseGraphQLClient {
123
124
  baseHeaders;
@@ -170,7 +171,7 @@ class BaseGraphQLClient {
170
171
  if (this.disposed) {
171
172
  // A fresh socket here would start the retry ladder its owner just ended;
172
173
  // the operation fails fast and typed instead, on the caller that asked.
173
- throw new ClientError({ errors: [{ message: "WS_CLIENT_DISPOSED", extensions: { endpoint: this.endpoint } }] }, "WS_CLIENT_DISPOSED");
174
+ throw this.clientDisposedError();
174
175
  }
175
176
  if (this.wsClient && this.wsClientStale) {
176
177
  this.disposeWsClient();
@@ -382,6 +383,11 @@ class BaseGraphQLClient {
382
383
  * sink callback, never the operation's lifetime, so a connected stream runs
383
384
  * for as long as the server streams it. Both bounds raise the same typed
384
385
  * code, so one consumer branch answers for both.
386
+ *
387
+ * Disposing the client ends the operation with WS_CLIENT_DISPOSED while it
388
+ * still waits on a handshake (#2102) or, as a query or a mutation, on its
389
+ * answer (#1572): graphql-ws would leave the first waiting forever and
390
+ * COMPLETE the second with no value — see settlePendingOperations.
385
391
  */
386
392
  executeWsOperation(document, variables, kind) {
387
393
  return new QueryObservable((subscriber) => {
@@ -390,6 +396,10 @@ class BaseGraphQLClient {
390
396
  // that replaced the boolean this method used to take.
391
397
  const singleDelivery = kind === WsOperationKind.MUTATION;
392
398
  const firstValueBudgetMs = this.getFirstValueBudgetMs(kind, this.operationNameOf(document));
399
+ // A query or a mutation is a REQUEST: its completion means something only
400
+ // once an answer arrived. A subscription is a stream its client may end at
401
+ // any point, so a disposal that completes it reports the truth (#1572).
402
+ const awaitsAnswer = kind !== WsOperationKind.SUBSCRIPTION;
393
403
  let disposed = false;
394
404
  // Delivery latch: once ANY sink callback has fired, the wire has
395
405
  // spoken and a socket close can no longer mean "delivery unknown".
@@ -407,8 +417,10 @@ class BaseGraphQLClient {
407
417
  // object and a browser a number, and the generated client must typecheck
408
418
  // in both without an @types/node-only name.
409
419
  let operationDeadline;
410
- // This operation's entry in the socket's pending-handshake registry,
411
- // present for exactly as long as the bound is armed (#2102).
420
+ // This operation's entry in the socket's pending registry, present for
421
+ // exactly as long as dispose() would otherwise leave it without an ending:
422
+ // while it waits on a handshake (#2102) and, for a request, until the
423
+ // wire answers it (#1572).
412
424
  let pendingEntry;
413
425
  const offConnectionEvents = [];
414
426
  // Disarms the first-value deadline. Reached on the first sink callback
@@ -419,27 +431,33 @@ class BaseGraphQLClient {
419
431
  clearTimeout(operationDeadline);
420
432
  operationDeadline = undefined;
421
433
  };
422
- // Disarms the bound AND deregisters the operation, because those are one
423
- // fact: a handshake that no longer needs bounding is one dispose() no
424
- // longer has to settle. Reached on the ACK, on the delivery latch and on
425
- // teardown — every way this operation stops waiting on a handshake.
434
+ // Disarms the handshake bound. Reached on the ACK, on the delivery latch
435
+ // and on teardown — every way this operation stops waiting on a handshake.
426
436
  const clearConnectionTimeout = () => {
427
- if (pendingEntry !== undefined) {
428
- WS_CONNECTION_STATE.get(client)?.pending.delete(pendingEntry);
429
- pendingEntry = undefined;
430
- }
431
437
  if (connectionTimer === undefined)
432
438
  return;
433
439
  clearTimeout(connectionTimer);
434
440
  connectionTimer = undefined;
435
441
  };
442
+ // Deregisters the operation: dispose() no longer has to end it. Reached on
443
+ // the delivery latch and on teardown and, for a subscription, which waits
444
+ // on nothing past its handshake, on the ACK. A request stays registered
445
+ // across the ACK — the handshake is over, its answer is still owed.
446
+ const leavePending = () => {
447
+ if (pendingEntry === undefined)
448
+ return;
449
+ WS_CONNECTION_STATE.get(client)?.pending.delete(pendingEntry);
450
+ pendingEntry = undefined;
451
+ };
436
452
  // The wire answering IS proof the handshake completed AND the first value
437
- // arrived, so the latch and BOTH bounds fall together — and a timer that
438
- // outlived its operation would hold the event loop open (a hung worker).
453
+ // arrived, so the latch, BOTH bounds and the registration fall together —
454
+ // and a timer that outlived its operation would hold the event loop open
455
+ // (a hung worker).
439
456
  const settle = () => {
440
457
  settled = true;
441
458
  clearOperationDeadline();
442
459
  clearConnectionTimeout();
460
+ leavePending();
443
461
  };
444
462
  // Armed BEFORE the operation is sent, so the budget measures the caller's
445
463
  // own wait rather than a window that opens once graphql-ws gets round to
@@ -527,7 +545,11 @@ class BaseGraphQLClient {
527
545
  // disposed and shouldRetry is untouched: graphql-ws keeps healing for the
528
546
  // next operation; only THIS one gives up (#2102).
529
547
  if (!(WS_CONNECTION_STATE.get(client)?.connected ?? false)) {
530
- offConnectionEvents.push(client.on("connected", () => { clearConnectionTimeout(); }));
548
+ offConnectionEvents.push(client.on("connected", () => {
549
+ clearConnectionTimeout();
550
+ if (!awaitsAnswer)
551
+ leavePending();
552
+ }));
531
553
  connectionTimer = setTimeout(() => {
532
554
  connectionTimer = undefined;
533
555
  // A residual guard, no longer the settlement: dispose() has already
@@ -571,23 +593,29 @@ class BaseGraphQLClient {
571
593
  const timerHandle = connectionTimer;
572
594
  if (typeof timerHandle === "object" && typeof timerHandle.unref === "function")
573
595
  timerHandle.unref();
574
- // Reporting nothing when the bound elapses on a dead connection leaves
575
- // something else to end this operation when its client ends — and
576
- // graphql-ws is not it: its dispose() speaks to the sinks of operations
577
- // its socket CARRIED, and a handshake that never completed carried none,
578
- // so this one would wait forever and whoever awaited it with it.
579
- // Registered for the life of the bound and settled by
580
- // settlePendingHandshakes, on the disposing owner's own stack (#2102).
596
+ }
597
+ // Reporting nothing when the bound elapses on a dead connection leaves
598
+ // something else to end this operation when its client ends — and
599
+ // graphql-ws is not it. Its dispose() speaks to the sinks of operations
600
+ // its socket CARRIED, and a handshake that never completed carried none,
601
+ // so that one would wait forever and whoever awaited it with it (#2102).
602
+ // A request it did carry hears a bare COMPLETE — no value and no error —
603
+ // so map never runs, catchError never fires, an await reports an
604
+ // anonymous EmptyError, and the request is indistinguishable from one
605
+ // never made (#1572: the sidebar of #1571 kept the previous tenant's
606
+ // entries across a channel switch that way). Registered while either can
607
+ // still happen, and settled by settlePendingOperations on the disposing
608
+ // owner's own stack, with the error a later operation already receives.
609
+ if (awaitsAnswer || connectionTimer !== undefined) {
581
610
  pendingEntry = {
582
611
  owner: this,
583
612
  abandon: () => {
584
613
  if (disposed || settled)
585
614
  return;
586
615
  // The same disarm a wire answer performs: the latch closes and the
587
- // bound is cleared, so nothing speaks twice for this operation.
616
+ // bounds are cleared, so nothing speaks twice for this operation.
588
617
  settle();
589
- const message = "WS_CLIENT_DISPOSED";
590
- subscriber.error(new ClientError({ errors: [{ message, extensions: { endpoint: this.endpoint } }] }, message));
618
+ subscriber.error(this.clientDisposedError());
591
619
  },
592
620
  };
593
621
  WS_CONNECTION_STATE.get(client)?.pending.add(pendingEntry);
@@ -609,6 +637,7 @@ class BaseGraphQLClient {
609
637
  disposed = true;
610
638
  clearOperationDeadline();
611
639
  clearConnectionTimeout();
640
+ leavePending();
612
641
  for (const off of offConnectionEvents)
613
642
  off();
614
643
  cleanup();
@@ -691,6 +720,19 @@ class BaseGraphQLClient {
691
720
  };
692
721
  return new ClientError({ errors: [{ message, extensions }] }, message);
693
722
  }
723
+ /**
724
+ * The ONE error a disposed client raises (#1994, #2102, #1572): for an
725
+ * operation started after dispose(), for one still waiting on a handshake,
726
+ * and for a query or a mutation still waiting on its answer. Named, so a
727
+ * consumer's error path tells an operation its own client cut off from one
728
+ * the server failed; carrying the endpoint, so a late one stays attributable.
729
+ * Never routed through the error handler: the disposal is the caller's own
730
+ * act, not a fault worth a notification.
731
+ */
732
+ clientDisposedError() {
733
+ const message = "WS_CLIENT_DISPOSED";
734
+ return new ClientError({ errors: [{ message, extensions: { endpoint: this.endpoint } }] }, message);
735
+ }
694
736
  /**
695
737
  * Uniform error contract for the sink-level error path. graphql-ws
696
738
  * delivers a server ErrorMessage payload as a raw GraphQLError[] —
@@ -857,22 +899,27 @@ class BaseGraphQLClient {
857
899
  return String(value);
858
900
  }
859
901
  /**
860
- * Ends every operation still waiting on a handshake this socket never
861
- * answered, where its owner stands (#2102). graphql-ws speaks to the sinks
862
- * of operations its socket CARRIED; an unanswered handshake carried none, so
863
- * its disposal says nothing to them and they wait forever on a client that
864
- * will never connect again — and the caller awaiting one waits with it
865
- * (measured from the far end as backends left idle in transaction on a wait
866
- * the client had already abandoned). Erroring from the BOUND is the thing
867
- * this must not do: that fires 10 s later, in whatever context is alive
868
- * then. dispose() is the owner's own stack, so a rejection lands there.
902
+ * Ends, where its owner stands, every operation graphql-ws's disposal would
903
+ * leave without an ending it can use, with WS_CLIENT_DISPOSED. graphql-ws
904
+ * speaks to the sinks of operations its socket CARRIED. An unanswered
905
+ * handshake carried none, so its disposal says nothing to them and they wait
906
+ * forever on a client that will never connect again — and the caller
907
+ * awaiting one waits with it (#2102; measured from the far end as backends
908
+ * left idle in transaction on a wait the client had already abandoned). A
909
+ * query or a mutation it carried unanswered it COMPLETES with no value,
910
+ * which no consumer can tell from a request never made (#1572). Erroring
911
+ * from the BOUND is the thing this must not do: that fires 10 s later, in
912
+ * whatever context is alive then. dispose() is the owner's own stack, so a
913
+ * rejection lands there. A subscription past its handshake is not here: a
914
+ * stream its client ended completes truthfully, and an operation whose
915
+ * answer arrived left the registry with it.
869
916
  *
870
917
  * The socket's owner ends every operation on it, forks included — the same
871
918
  * reason the socket rather than the client carries the disposed flag. A fork
872
919
  * owns no socket and ends only what it started, leaving its owner on the
873
920
  * wire. Idempotent: an entry settles once and deregisters itself doing so.
874
921
  */
875
- settlePendingHandshakes() {
922
+ settlePendingOperations() {
876
923
  const wsClient = this.wsClient;
877
924
  if (!wsClient)
878
925
  return;
@@ -888,15 +935,18 @@ class BaseGraphQLClient {
888
935
  }
889
936
  /**
890
937
  * Terminal (#1994). Ends the socket, ends any retry in flight, ends every
891
- * operation still waiting on a handshake (#2102), and refuses every later
892
- * operation with WS_CLIENT_DISPOSED — a disposed client never mints another
893
- * socket. Idempotent and synchronous; never throws.
938
+ * operation still waiting on a handshake (#2102) and every query or
939
+ * mutation still waiting on its answer (#1572) with WS_CLIENT_DISPOSED, and
940
+ * refuses every later operation with the same error — a disposed client
941
+ * never mints another socket. An answer that already arrived is kept, and a
942
+ * subscription the socket carried completes. Idempotent and synchronous;
943
+ * never throws.
894
944
  */
895
945
  dispose() {
896
946
  this.disposed = true;
897
947
  // Before the socket goes: erroring a subscriber tears its graphql-ws
898
- // subscription down, so the disposal that follows finds nothing pending.
899
- this.settlePendingHandshakes();
948
+ // subscription down, so the disposal that follows finds none of them.
949
+ this.settlePendingOperations();
900
950
  this.disposeWsClient();
901
951
  }
902
952
  fork(transactionToken) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/sdk",
3
- "version": "1.202610241216.0",
3
+ "version": "1.202610241557.0",
4
4
  "description": "ForgeCart Client SDK",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -236,11 +236,14 @@ const WS_FIRST_VALUE_BUDGET_MS_BY_OPERATION: Readonly<Record<string, number>> =
236
236
  };
237
237
 
238
238
  /**
239
- * One operation still waiting on a handshake its socket has not answered.
240
- * `owner` is the client that started it: the socket's owner ends every
241
- * pending operation on that socket, a fork() only the ones it started.
239
+ * One operation still waiting on the wire, which graphql-ws's own disposal
240
+ * would leave without an ending it can use: one waiting on a handshake its
241
+ * socket has not answered (#2102), or a query or a mutation waiting on its
242
+ * answer (#1572). `owner` is the client that started it: the socket's owner
243
+ * ends every pending operation on that socket, a fork() only the ones it
244
+ * started.
242
245
  */
243
- interface PendingHandshake {
246
+ interface PendingOperation {
244
247
  owner: BaseGraphQLClient;
245
248
  abandon: () => void;
246
249
  }
@@ -252,9 +255,10 @@ interface PendingHandshake {
252
255
  // `disposed` is that socket's terminal end, and it lives here rather than
253
256
  // on the client because every client still holding the socket — the forks
254
257
  // included — is off the wire the moment its owner ends it. `pending` is
255
- // socket-level for exactly that reason too (#2102): a handshake belongs to
256
- // the socket, so ending the socket must end everything still waiting on it.
257
- const WS_CONNECTION_STATE = new WeakMap<WsClient, { connected: boolean; disposed: boolean; pending: Set<PendingHandshake> }>();
258
+ // socket-level for exactly that reason too (#2102, #1572): an operation
259
+ // waiting on the wire belongs to the socket, so ending the socket must end
260
+ // everything still waiting on it.
261
+ const WS_CONNECTION_STATE = new WeakMap<WsClient, { connected: boolean; disposed: boolean; pending: Set<PendingOperation> }>();
258
262
 
259
263
  export class BaseGraphQLClient {
260
264
  private readonly logger = new SDKLogger();
@@ -310,10 +314,7 @@ export class BaseGraphQLClient {
310
314
  if (this.disposed) {
311
315
  // A fresh socket here would start the retry ladder its owner just ended;
312
316
  // the operation fails fast and typed instead, on the caller that asked.
313
- throw new ClientError(
314
- { errors: [{ message: "WS_CLIENT_DISPOSED", extensions: { endpoint: this.endpoint } }] },
315
- "WS_CLIENT_DISPOSED",
316
- );
317
+ throw this.clientDisposedError();
317
318
  }
318
319
  if (this.wsClient && this.wsClientStale) {
319
320
  this.disposeWsClient();
@@ -351,7 +352,7 @@ export class BaseGraphQLClient {
351
352
  // a dead endpoint for 32-46 s and erroring onto whoever runs then.
352
353
  shouldRetry: () => !this.disposed,
353
354
  });
354
- const connectionState = { connected: false, disposed: false, pending: new Set<PendingHandshake>() };
355
+ const connectionState = { connected: false, disposed: false, pending: new Set<PendingOperation>() };
355
356
  WS_CONNECTION_STATE.set(this.wsClient, connectionState);
356
357
  this.wsClient.on("connected", () => {
357
358
  connectionState.connected = true;
@@ -538,6 +539,11 @@ export class BaseGraphQLClient {
538
539
  * sink callback, never the operation's lifetime, so a connected stream runs
539
540
  * for as long as the server streams it. Both bounds raise the same typed
540
541
  * code, so one consumer branch answers for both.
542
+ *
543
+ * Disposing the client ends the operation with WS_CLIENT_DISPOSED while it
544
+ * still waits on a handshake (#2102) or, as a query or a mutation, on its
545
+ * answer (#1572): graphql-ws would leave the first waiting forever and
546
+ * COMPLETE the second with no value — see settlePendingOperations.
541
547
  */
542
548
  private executeWsOperation<T>(
543
549
  document: string,
@@ -550,6 +556,10 @@ export class BaseGraphQLClient {
550
556
  // that replaced the boolean this method used to take.
551
557
  const singleDelivery = kind === WsOperationKind.MUTATION;
552
558
  const firstValueBudgetMs = this.getFirstValueBudgetMs(kind, this.operationNameOf(document));
559
+ // A query or a mutation is a REQUEST: its completion means something only
560
+ // once an answer arrived. A subscription is a stream its client may end at
561
+ // any point, so a disposal that completes it reports the truth (#1572).
562
+ const awaitsAnswer = kind !== WsOperationKind.SUBSCRIPTION;
553
563
  let disposed = false;
554
564
  // Delivery latch: once ANY sink callback has fired, the wire has
555
565
  // spoken and a socket close can no longer mean "delivery unknown".
@@ -567,9 +577,11 @@ export class BaseGraphQLClient {
567
577
  // object and a browser a number, and the generated client must typecheck
568
578
  // in both without an @types/node-only name.
569
579
  let operationDeadline: ReturnType<typeof setTimeout> | undefined;
570
- // This operation's entry in the socket's pending-handshake registry,
571
- // present for exactly as long as the bound is armed (#2102).
572
- let pendingEntry: PendingHandshake | undefined;
580
+ // This operation's entry in the socket's pending registry, present for
581
+ // exactly as long as dispose() would otherwise leave it without an ending:
582
+ // while it waits on a handshake (#2102) and, for a request, until the
583
+ // wire answers it (#1572).
584
+ let pendingEntry: PendingOperation | undefined;
573
585
  const offConnectionEvents: (() => void)[] = [];
574
586
 
575
587
  // Disarms the first-value deadline. Reached on the first sink callback
@@ -580,27 +592,33 @@ export class BaseGraphQLClient {
580
592
  operationDeadline = undefined;
581
593
  };
582
594
 
583
- // Disarms the bound AND deregisters the operation, because those are one
584
- // fact: a handshake that no longer needs bounding is one dispose() no
585
- // longer has to settle. Reached on the ACK, on the delivery latch and on
586
- // teardown — every way this operation stops waiting on a handshake.
595
+ // Disarms the handshake bound. Reached on the ACK, on the delivery latch
596
+ // and on teardown — every way this operation stops waiting on a handshake.
587
597
  const clearConnectionTimeout = (): void => {
588
- if (pendingEntry !== undefined) {
589
- WS_CONNECTION_STATE.get(client)?.pending.delete(pendingEntry);
590
- pendingEntry = undefined;
591
- }
592
598
  if (connectionTimer === undefined) return;
593
599
  clearTimeout(connectionTimer);
594
600
  connectionTimer = undefined;
595
601
  };
596
602
 
603
+ // Deregisters the operation: dispose() no longer has to end it. Reached on
604
+ // the delivery latch and on teardown and, for a subscription, which waits
605
+ // on nothing past its handshake, on the ACK. A request stays registered
606
+ // across the ACK — the handshake is over, its answer is still owed.
607
+ const leavePending = (): void => {
608
+ if (pendingEntry === undefined) return;
609
+ WS_CONNECTION_STATE.get(client)?.pending.delete(pendingEntry);
610
+ pendingEntry = undefined;
611
+ };
612
+
597
613
  // The wire answering IS proof the handshake completed AND the first value
598
- // arrived, so the latch and BOTH bounds fall together — and a timer that
599
- // outlived its operation would hold the event loop open (a hung worker).
614
+ // arrived, so the latch, BOTH bounds and the registration fall together —
615
+ // and a timer that outlived its operation would hold the event loop open
616
+ // (a hung worker).
600
617
  const settle = (): void => {
601
618
  settled = true;
602
619
  clearOperationDeadline();
603
620
  clearConnectionTimeout();
621
+ leavePending();
604
622
  };
605
623
 
606
624
  // Armed BEFORE the operation is sent, so the budget measures the caller's
@@ -688,7 +706,10 @@ export class BaseGraphQLClient {
688
706
  // disposed and shouldRetry is untouched: graphql-ws keeps healing for the
689
707
  // next operation; only THIS one gives up (#2102).
690
708
  if (!(WS_CONNECTION_STATE.get(client)?.connected ?? false)) {
691
- offConnectionEvents.push(client.on("connected", () => { clearConnectionTimeout(); }));
709
+ offConnectionEvents.push(client.on("connected", () => {
710
+ clearConnectionTimeout();
711
+ if (!awaitsAnswer) leavePending();
712
+ }));
692
713
  connectionTimer = setTimeout(() => {
693
714
  connectionTimer = undefined;
694
715
  // A residual guard, no longer the settlement: dispose() has already
@@ -730,22 +751,29 @@ export class BaseGraphQLClient {
730
751
  // client stays correct in both runtimes with no @types/node-only typing.
731
752
  const timerHandle = connectionTimer as unknown as { unref?: () => void } | number;
732
753
  if (typeof timerHandle === "object" && typeof timerHandle.unref === "function") timerHandle.unref();
733
- // Reporting nothing when the bound elapses on a dead connection leaves
734
- // something else to end this operation when its client ends — and
735
- // graphql-ws is not it: its dispose() speaks to the sinks of operations
736
- // its socket CARRIED, and a handshake that never completed carried none,
737
- // so this one would wait forever and whoever awaited it with it.
738
- // Registered for the life of the bound and settled by
739
- // settlePendingHandshakes, on the disposing owner's own stack (#2102).
754
+ }
755
+
756
+ // Reporting nothing when the bound elapses on a dead connection leaves
757
+ // something else to end this operation when its client ends — and
758
+ // graphql-ws is not it. Its dispose() speaks to the sinks of operations
759
+ // its socket CARRIED, and a handshake that never completed carried none,
760
+ // so that one would wait forever and whoever awaited it with it (#2102).
761
+ // A request it did carry hears a bare COMPLETE — no value and no error —
762
+ // so map never runs, catchError never fires, an await reports an
763
+ // anonymous EmptyError, and the request is indistinguishable from one
764
+ // never made (#1572: the sidebar of #1571 kept the previous tenant's
765
+ // entries across a channel switch that way). Registered while either can
766
+ // still happen, and settled by settlePendingOperations on the disposing
767
+ // owner's own stack, with the error a later operation already receives.
768
+ if (awaitsAnswer || connectionTimer !== undefined) {
740
769
  pendingEntry = {
741
770
  owner: this,
742
771
  abandon: () => {
743
772
  if (disposed || settled) return;
744
773
  // The same disarm a wire answer performs: the latch closes and the
745
- // bound is cleared, so nothing speaks twice for this operation.
774
+ // bounds are cleared, so nothing speaks twice for this operation.
746
775
  settle();
747
- const message = "WS_CLIENT_DISPOSED";
748
- subscriber.error(new ClientError({ errors: [{ message, extensions: { endpoint: this.endpoint } }] }, message));
776
+ subscriber.error(this.clientDisposedError());
749
777
  },
750
778
  };
751
779
  WS_CONNECTION_STATE.get(client)?.pending.add(pendingEntry);
@@ -768,6 +796,7 @@ export class BaseGraphQLClient {
768
796
  disposed = true;
769
797
  clearOperationDeadline();
770
798
  clearConnectionTimeout();
799
+ leavePending();
771
800
  for (const off of offConnectionEvents) off();
772
801
  cleanup();
773
802
  };
@@ -858,6 +887,20 @@ export class BaseGraphQLClient {
858
887
  return new ClientError({ errors: [{ message, extensions }] }, message);
859
888
  }
860
889
 
890
+ /**
891
+ * The ONE error a disposed client raises (#1994, #2102, #1572): for an
892
+ * operation started after dispose(), for one still waiting on a handshake,
893
+ * and for a query or a mutation still waiting on its answer. Named, so a
894
+ * consumer's error path tells an operation its own client cut off from one
895
+ * the server failed; carrying the endpoint, so a late one stays attributable.
896
+ * Never routed through the error handler: the disposal is the caller's own
897
+ * act, not a fault worth a notification.
898
+ */
899
+ private clientDisposedError(): ClientError {
900
+ const message = "WS_CLIENT_DISPOSED";
901
+ return new ClientError({ errors: [{ message, extensions: { endpoint: this.endpoint } }] }, message);
902
+ }
903
+
861
904
  /**
862
905
  * Uniform error contract for the sink-level error path. graphql-ws
863
906
  * delivers a server ErrorMessage payload as a raw GraphQLError[] —
@@ -1012,22 +1055,27 @@ export class BaseGraphQLClient {
1012
1055
  }
1013
1056
 
1014
1057
  /**
1015
- * Ends every operation still waiting on a handshake this socket never
1016
- * answered, where its owner stands (#2102). graphql-ws speaks to the sinks
1017
- * of operations its socket CARRIED; an unanswered handshake carried none, so
1018
- * its disposal says nothing to them and they wait forever on a client that
1019
- * will never connect again — and the caller awaiting one waits with it
1020
- * (measured from the far end as backends left idle in transaction on a wait
1021
- * the client had already abandoned). Erroring from the BOUND is the thing
1022
- * this must not do: that fires 10 s later, in whatever context is alive
1023
- * then. dispose() is the owner's own stack, so a rejection lands there.
1058
+ * Ends, where its owner stands, every operation graphql-ws's disposal would
1059
+ * leave without an ending it can use, with WS_CLIENT_DISPOSED. graphql-ws
1060
+ * speaks to the sinks of operations its socket CARRIED. An unanswered
1061
+ * handshake carried none, so its disposal says nothing to them and they wait
1062
+ * forever on a client that will never connect again — and the caller
1063
+ * awaiting one waits with it (#2102; measured from the far end as backends
1064
+ * left idle in transaction on a wait the client had already abandoned). A
1065
+ * query or a mutation it carried unanswered it COMPLETES with no value,
1066
+ * which no consumer can tell from a request never made (#1572). Erroring
1067
+ * from the BOUND is the thing this must not do: that fires 10 s later, in
1068
+ * whatever context is alive then. dispose() is the owner's own stack, so a
1069
+ * rejection lands there. A subscription past its handshake is not here: a
1070
+ * stream its client ended completes truthfully, and an operation whose
1071
+ * answer arrived left the registry with it.
1024
1072
  *
1025
1073
  * The socket's owner ends every operation on it, forks included — the same
1026
1074
  * reason the socket rather than the client carries the disposed flag. A fork
1027
1075
  * owns no socket and ends only what it started, leaving its owner on the
1028
1076
  * wire. Idempotent: an entry settles once and deregisters itself doing so.
1029
1077
  */
1030
- private settlePendingHandshakes(): void {
1078
+ private settlePendingOperations(): void {
1031
1079
  const wsClient = this.wsClient;
1032
1080
  if (!wsClient) return;
1033
1081
  const pending = WS_CONNECTION_STATE.get(wsClient)?.pending;
@@ -1041,15 +1089,18 @@ export class BaseGraphQLClient {
1041
1089
 
1042
1090
  /**
1043
1091
  * Terminal (#1994). Ends the socket, ends any retry in flight, ends every
1044
- * operation still waiting on a handshake (#2102), and refuses every later
1045
- * operation with WS_CLIENT_DISPOSED — a disposed client never mints another
1046
- * socket. Idempotent and synchronous; never throws.
1092
+ * operation still waiting on a handshake (#2102) and every query or
1093
+ * mutation still waiting on its answer (#1572) with WS_CLIENT_DISPOSED, and
1094
+ * refuses every later operation with the same error — a disposed client
1095
+ * never mints another socket. An answer that already arrived is kept, and a
1096
+ * subscription the socket carried completes. Idempotent and synchronous;
1097
+ * never throws.
1047
1098
  */
1048
1099
  dispose(): void {
1049
1100
  this.disposed = true;
1050
1101
  // Before the socket goes: erroring a subscriber tears its graphql-ws
1051
- // subscription down, so the disposal that follows finds nothing pending.
1052
- this.settlePendingHandshakes();
1102
+ // subscription down, so the disposal that follows finds none of them.
1103
+ this.settlePendingOperations();
1053
1104
  this.disposeWsClient();
1054
1105
  }
1055
1106