@fitchmultz/pi-subagents 0.52.19 → 0.52.21

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 (56) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/bridge-presence.js +30 -18
  13. package/dist/pi-intercom/bridge-wake.js +60 -37
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-last.js +6 -18
  16. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  17. package/dist/pi-intercom/broker/client-park.js +22 -6
  18. package/dist/pi-intercom/broker/client.d.ts +3 -2
  19. package/dist/pi-intercom/broker/client.js +104 -112
  20. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  21. package/dist/pi-intercom/broker/connection.js +48 -33
  22. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  23. package/dist/pi-intercom/broker/framing.js +19 -12
  24. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  25. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  26. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  27. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  28. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  29. package/dist/pi-intercom/connection.js +5 -8
  30. package/dist/pi-intercom/inbound-journal.js +5 -2
  31. package/dist/shared/journal-frames.js +19 -24
  32. package/docs/development.md +6 -0
  33. package/package.json +1 -1
  34. package/src/agent/contracts.ts +12 -3
  35. package/src/agent/index.ts +10 -10
  36. package/src/agent/legacy.ts +5 -5
  37. package/src/agent/notifications.ts +35 -15
  38. package/src/agent/parent-check.ts +40 -0
  39. package/src/agent/process.ts +53 -55
  40. package/src/agent/runner.ts +65 -65
  41. package/src/agent/storage.ts +18 -5
  42. package/src/agent/view.ts +1 -4
  43. package/src/pi-intercom/bridge-presence.ts +37 -18
  44. package/src/pi-intercom/bridge-wake.ts +70 -38
  45. package/src/pi-intercom/broker/broker.ts +5 -37
  46. package/src/pi-intercom/broker/client-last.ts +14 -25
  47. package/src/pi-intercom/broker/client-park.ts +27 -6
  48. package/src/pi-intercom/broker/client.ts +127 -126
  49. package/src/pi-intercom/broker/connection.ts +58 -39
  50. package/src/pi-intercom/broker/framing.ts +24 -12
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  53. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  54. package/src/pi-intercom/connection.ts +9 -9
  55. package/src/pi-intercom/inbound-journal.ts +5 -2
  56. package/src/shared/journal-frames.ts +22 -23
@@ -4,20 +4,41 @@ import { writeMessage } from "./framing.ts";
4
4
  /** Parking negotiation and its single outstanding broker acknowledgement. */
5
5
  export class ParkRequests {
6
6
  supported = false;
7
- private acknowledged?: ((parked: boolean) => void) | undefined;
7
+ private state:
8
+ | { readonly kind: "idle" }
9
+ | {
10
+ readonly kind: "pending";
11
+ readonly promise: Promise<boolean>;
12
+ readonly resolve: (parked: boolean) => void;
13
+ } = { kind: "idle" };
8
14
  settle(parked: boolean): void {
9
- this.acknowledged?.(parked);
15
+ const state = this.state;
16
+ this.state = { kind: "idle" };
17
+ if (state.kind === "pending") {
18
+ state.resolve(parked);
19
+ }
10
20
  }
11
21
  /** True once the broker retains later input; false when it cannot or the socket closed. */
12
22
  request(socket: () => net.Socket, on: boolean): Promise<boolean> {
13
- return new Promise((resolve) => {
14
- this.acknowledged = on && this.supported ? resolve : undefined;
23
+ if (on && this.state.kind === "pending") {
24
+ return this.state.promise;
25
+ }
26
+ const { promise, resolve, reject } = Promise.withResolvers<boolean>();
27
+ this.settle(false);
28
+ if (on && this.supported) {
29
+ this.state = { kind: "pending", promise, resolve };
30
+ }
31
+ try {
15
32
  if (this.supported) {
16
33
  writeMessage(socket(), { type: on ? "park" : "unpark" });
17
34
  }
18
- if (this.acknowledged === undefined) {
35
+ if (this.state.kind === "idle") {
19
36
  resolve(false);
20
37
  }
21
- });
38
+ } catch (error) {
39
+ this.state = { kind: "idle" };
40
+ reject(error);
41
+ }
42
+ return promise;
22
43
  }
23
44
  }
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
4
4
  import { writeMessage } from "./framing.ts";
5
5
  import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
6
  import { SnapshotReader } from "./snapshot-reader.ts";
7
+ import { PendingRequests } from "./pending-requests.ts";
7
8
  import { LastMessageRequests, lastRequest } from "./client-last.ts";
8
9
  import { ParkRequests } from "./client-park.ts";
9
10
  import { WakeMessages } from "./client-wake.ts";
@@ -62,6 +63,10 @@ function messageDelivery(options: SendOptions): MessageDelivery | undefined {
62
63
  return "steer";
63
64
  }
64
65
 
66
+ type ClientPhase =
67
+ | { readonly kind: "idle" | "connected" }
68
+ | { readonly kind: "connecting" | "closing"; readonly task: Promise<void> };
69
+
65
70
  export class IntercomClient extends EventEmitter {
66
71
  private connection: BrokerConnection | null = null;
67
72
  private _sessionId: string | null = null;
@@ -70,20 +75,16 @@ export class IntercomClient extends EventEmitter {
70
75
  readonly wakes = new WakeMessages(this, () => this.requireActiveSocket());
71
76
  private guardedDeliverySupported = false;
72
77
  private readonly lastRequests: LastMessageRequests;
73
- private readonly pendingSends = new Map<
74
- string,
75
- { readonly resolve: (r: SendResult) => void; readonly reject: (e: Readonly<Error>) => void }
76
- >();
77
- private readonly pendingLists = new Map<
78
- string,
79
- {
80
- readonly resolve: (snapshot: SessionSnapshot) => void;
81
- readonly reject: (e: Readonly<Error>) => void;
82
- readonly reader: SnapshotReader;
83
- }
84
- >();
85
- private connecting = false;
86
- private disconnecting = false;
78
+ private readonly pendingSends = new PendingRequests<{
79
+ readonly resolve: (r: SendResult) => void;
80
+ readonly reject: (e: Readonly<Error>) => void;
81
+ }>();
82
+ private readonly pendingLists = new PendingRequests<{
83
+ readonly resolve: (snapshot: SessionSnapshot) => void;
84
+ readonly reject: (e: Readonly<Error>) => void;
85
+ readonly reader: SnapshotReader;
86
+ }>();
87
+ private phase: ClientPhase = { kind: "idle" };
87
88
  private readonly sendTimeoutMs: number;
88
89
  private readonly listTimeoutMs: number;
89
90
 
@@ -96,14 +97,8 @@ export class IntercomClient extends EventEmitter {
96
97
 
97
98
  private failPending(error: Readonly<Error>): void {
98
99
  this.lastRequests.fail(error);
99
- for (const pending of this.pendingSends.values()) {
100
- pending.reject(error);
101
- }
102
- this.pendingSends.clear();
103
- for (const pending of this.pendingLists.values()) {
104
- pending.reject(error);
105
- }
106
- this.pendingLists.clear();
100
+ this.pendingSends.fail(error);
101
+ this.pendingLists.fail(error);
107
102
  }
108
103
 
109
104
  get sessionId(): string | null {
@@ -119,13 +114,13 @@ export class IntercomClient extends EventEmitter {
119
114
  isConnected(): boolean {
120
115
  return (
121
116
  (this._sessionId ?? "").length > 0 &&
122
- !this.disconnecting &&
117
+ this.phase.kind !== "closing" &&
123
118
  this.connection?.isActive() === true
124
119
  );
125
120
  }
126
121
 
127
122
  private requireActiveSocket(): net.Socket {
128
- if (this.disconnecting) {
123
+ if (this.phase.kind === "closing") {
129
124
  throw new Error("Client disconnecting");
130
125
  }
131
126
  const connection = this.connection;
@@ -139,60 +134,71 @@ export class IntercomClient extends EventEmitter {
139
134
  }
140
135
 
141
136
  async connect(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void> {
142
- if (this.connection || this.connecting) {
137
+ if (this.connection || this.phase.kind !== "idle") {
143
138
  throw new Error("Already connected");
144
139
  }
145
- this.connecting = true;
140
+ const task = this.establish(session, requestedId);
141
+ this.phase = { kind: "connecting", task };
146
142
  try {
147
- const socket = await connectBrokerSocket();
148
- const connection = new BrokerConnection(socket, {
149
- message: (message) => this.handleBrokerMessage(message),
150
- error: (error) => {
151
- this.emit("error", error);
152
- },
153
- closed: (error, expected) => {
154
- if (this.connection !== connection) {
155
- return;
156
- }
157
- const established = this._sessionId !== null;
158
- this.connection = null;
159
- this._sessionId = null;
160
- this._topicsSupported = false;
161
- this.parking.settle(false);
162
- this.guardedDeliverySupported = false;
163
- this.wakes.supported = false;
164
- this.disconnecting = false;
165
- this.failPending(error);
166
- if (established && !expected) {
167
- this.emit("disconnected", error);
168
- }
169
- },
170
- });
171
- this.connection = connection;
172
- const { topics, subscriptions, ...identity } = session;
173
- try {
174
- await connection.start({
175
- type: "register",
176
- session: identity,
177
- requestedId,
178
- topicFrames: true,
179
- lastMessages: true,
180
- parking: true,
181
- receiptReads: 1,
182
- guardedDelivery: 1,
183
- ...this.wakes.registration(),
184
- });
185
- } catch (error) {
186
- if (this.connection === connection) {
187
- this.connection = null;
188
- }
189
- throw error;
190
- }
191
- if (this.supportsTopics) {
192
- await this.restoreTopics(topics, subscriptions);
193
- }
143
+ await task;
194
144
  } finally {
195
- this.connecting = false;
145
+ if (this.phase.kind === "connecting" && this.phase.task === task) {
146
+ this.phase = { kind: this.isConnected() ? "connected" : "idle" };
147
+ }
148
+ }
149
+ }
150
+
151
+ private async establish(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void> {
152
+ const socket = await connectBrokerSocket();
153
+ const connection = new BrokerConnection(socket, {
154
+ message: (message) => this.handleBrokerMessage(message),
155
+ error: (error) => {
156
+ this.emit("error", error);
157
+ },
158
+ closed: (error, expected) => {
159
+ if (this.connection !== connection) {
160
+ return;
161
+ }
162
+ const established = this._sessionId !== null;
163
+ this.connection = null;
164
+ this._sessionId = null;
165
+ this._topicsSupported = false;
166
+ this.parking.settle(false);
167
+ this.guardedDeliverySupported = false;
168
+ this.wakes.supported = false;
169
+ if (this.phase.kind === "connected") {
170
+ this.phase = { kind: "idle" };
171
+ }
172
+ this.failPending(error);
173
+ if (established && !expected) {
174
+ this.emit("disconnected", error);
175
+ }
176
+ },
177
+ });
178
+ this.connection = connection;
179
+ if (this.phase.kind === "closing") {
180
+ await connection.disconnect();
181
+ throw new Error("Client disconnecting");
182
+ }
183
+ const { topics, subscriptions, ...identity } = session;
184
+ try {
185
+ await connection.start({
186
+ type: "register",
187
+ session: identity,
188
+ requestedId,
189
+ topicFrames: true,
190
+ lastMessages: true,
191
+ parking: true,
192
+ receiptReads: 1,
193
+ guardedDelivery: 1,
194
+ ...this.wakes.registration(),
195
+ });
196
+ } catch (error) {
197
+ await connection.disconnect();
198
+ throw error;
199
+ }
200
+ if (this.supportsTopics) {
201
+ await this.restoreTopics(topics, subscriptions);
196
202
  }
197
203
  }
198
204
 
@@ -301,12 +307,11 @@ export class IntercomClient extends EventEmitter {
301
307
  if (type !== "delivered" && typeof reason !== "string") {
302
308
  throw new Error(`Invalid ${errorMessage(type)} message`);
303
309
  }
304
- const pending = this.pendingSends.get(messageId);
310
+ const pending = this.pendingSends.take(messageId);
305
311
  // Late responses are harmless after the caller has timed out.
306
312
  if (!pending) {
307
313
  return;
308
314
  }
309
- this.pendingSends.delete(messageId);
310
315
  pending.resolve({
311
316
  id: messageId,
312
317
  accepted: type !== "delivery_failed",
@@ -330,14 +335,12 @@ export class IntercomClient extends EventEmitter {
330
335
  return;
331
336
  }
332
337
  if (typeof error === "string") {
333
- this.pendingLists.delete(requestId);
334
- pending.reject(new Error(error));
338
+ this.pendingLists.take(requestId)?.reject(new Error(error));
335
339
  return;
336
340
  }
337
341
  pending.reader.append(sessions, receipts);
338
342
  if (more !== true) {
339
- this.pendingLists.delete(requestId);
340
- pending.resolve(pending.reader.finish());
343
+ this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
341
344
  }
342
345
  }
343
346
 
@@ -345,14 +348,30 @@ export class IntercomClient extends EventEmitter {
345
348
  return this.parking.request(() => this.requireActiveSocket(), on);
346
349
  }
347
350
 
348
- async disconnect(): Promise<void> {
349
- const connection = this.connection;
350
- if (!connection) {
351
- return;
351
+ disconnect(): Promise<void> {
352
+ const phase = this.phase;
353
+ if (phase.kind === "closing") {
354
+ return phase.task;
352
355
  }
353
- this.disconnecting = true;
356
+ const connecting = phase.kind === "connecting" ? phase.task : undefined;
357
+ const task = Promise.resolve().then(() => this.close(connecting));
358
+ this.phase = { kind: "closing", task };
359
+ return task;
360
+ }
361
+
362
+ private async close(connecting: Promise<void> | undefined): Promise<void> {
354
363
  this.failPending(new Error("Client disconnected"));
355
- await connection.disconnect();
364
+ try {
365
+ await this.connection?.disconnect();
366
+ try {
367
+ await connecting;
368
+ } catch {
369
+ // The connect caller owns acquisition failure; close still joins any acquired socket.
370
+ }
371
+ await this.connection?.disconnect();
372
+ } finally {
373
+ this.phase = { kind: "idle" };
374
+ }
356
375
  }
357
376
 
358
377
  listSessions(): Promise<SessionInfo[]> {
@@ -396,8 +415,8 @@ export class IntercomClient extends EventEmitter {
396
415
 
397
416
  return new Promise((resolve, reject) => {
398
417
  const requestId = randomUUID();
418
+ const reader = new SnapshotReader();
399
419
  const wrappedResolve = (snapshot: SessionSnapshot) => {
400
- clearTimeout(timeout);
401
420
  try {
402
421
  // Commit confirmed subscriptions before handling a following message in the same read.
403
422
  onAccepted?.(snapshot);
@@ -407,23 +426,19 @@ export class IntercomClient extends EventEmitter {
407
426
  }
408
427
  };
409
428
  const wrappedReject = (error: Readonly<Error>) => {
410
- clearTimeout(timeout);
429
+ reader.fail();
411
430
  reject(error);
412
431
  };
413
- const timeout = setTimeout(() => {
414
- if (this.pendingLists.has(requestId)) {
415
- this.pendingLists.delete(requestId);
416
- wrappedReject(
417
- Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
418
- );
419
- }
420
- }, this.listTimeoutMs);
421
- timeout.unref();
422
- this.pendingLists.set(requestId, {
423
- resolve: wrappedResolve,
424
- reject: wrappedReject,
425
- reader: new SnapshotReader(),
426
- });
432
+ this.pendingLists.set(
433
+ requestId,
434
+ {
435
+ resolve: wrappedResolve,
436
+ reject: wrappedReject,
437
+ reader,
438
+ },
439
+ this.listTimeoutMs,
440
+ () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
441
+ );
427
442
  try {
428
443
  writeMessage(socket, {
429
444
  type: "list",
@@ -432,9 +447,7 @@ export class IntercomClient extends EventEmitter {
432
447
  ...(change ? { change } : {}),
433
448
  });
434
449
  } catch (error) {
435
- clearTimeout(timeout);
436
- this.pendingLists.delete(requestId);
437
- reject(toError(error));
450
+ this.pendingLists.take(requestId)?.reject(toError(error));
438
451
  }
439
452
  });
440
453
  }
@@ -456,7 +469,7 @@ export class IntercomClient extends EventEmitter {
456
469
  }
457
470
 
458
471
  const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
459
- if (this.pendingSends.has(messageId)) {
472
+ if (this.pendingSends.get(messageId)) {
460
473
  return Promise.reject(new Error("Message ID already has a pending send"));
461
474
  }
462
475
  const message: Message = {
@@ -482,22 +495,12 @@ export class IntercomClient extends EventEmitter {
482
495
  return Promise.reject(new Error("Invalid message format"));
483
496
  }
484
497
  return new Promise((resolve, reject) => {
485
- const wrappedResolve = (result: SendResult) => {
486
- clearTimeout(timeout);
487
- resolve(result);
488
- };
489
- const wrappedReject = (error: Readonly<Error>) => {
490
- clearTimeout(timeout);
491
- reject(error);
492
- };
493
- const timeout = setTimeout(() => {
494
- if (this.pendingSends.has(messageId)) {
495
- this.pendingSends.delete(messageId);
496
- wrappedReject(new Error("Send timeout"));
497
- }
498
- }, this.sendTimeoutMs);
499
- timeout.unref();
500
- this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
498
+ this.pendingSends.set(
499
+ messageId,
500
+ { resolve, reject },
501
+ this.sendTimeoutMs,
502
+ () => new Error("Send timeout"),
503
+ );
501
504
 
502
505
  try {
503
506
  writeMessage(socket, {
@@ -506,9 +509,7 @@ export class IntercomClient extends EventEmitter {
506
509
  message: this._topicsSupported ? compactTopicMessage(message) : message,
507
510
  });
508
511
  } catch (error) {
509
- clearTimeout(timeout);
510
- this.pendingSends.delete(messageId);
511
- reject(toError(error));
512
+ this.pendingSends.take(messageId)?.reject(toError(error));
512
513
  }
513
514
  });
514
515
  }
@@ -524,7 +525,7 @@ export class IntercomClient extends EventEmitter {
524
525
  readonly subscriptions?: SessionInfo["subscriptions"] | undefined;
525
526
  readonly topics?: readonly TopicUpdate[] | undefined;
526
527
  }): void {
527
- if (this.disconnecting) {
528
+ if (this.phase.kind === "closing") {
528
529
  return;
529
530
  }
530
531
 
@@ -43,16 +43,20 @@ interface ConnectionHandlers {
43
43
  readonly closed: (error: Readonly<Error>, expected: boolean) => void;
44
44
  }
45
45
 
46
+ type ConnectionPhase =
47
+ | { readonly kind: "acquired" | "active" | "failed" | "closed" }
48
+ | {
49
+ readonly kind: "registering";
50
+ readonly resolve: () => void;
51
+ readonly reject: (error: Readonly<Error>) => void;
52
+ readonly timer: NodeJS.Timeout;
53
+ }
54
+ | { readonly kind: "closing"; readonly task: Promise<void>; readonly registered: boolean };
55
+
46
56
  /** One socket owns registration, read/error listeners, and graceful shutdown. */
47
57
  export class BrokerConnection {
48
- private registered = false;
49
- private closing = false;
58
+ private phase: ConnectionPhase = { kind: "acquired" };
50
59
  private failure: Readonly<Error> | null = null;
51
- private registration: {
52
- readonly resolve: () => void;
53
- readonly reject: (error: Readonly<Error>) => void;
54
- } | null = null;
55
- private registrationTimer: NodeJS.Timeout | null = null;
56
60
  private readonly reader: (data: Buffer) => void;
57
61
 
58
62
  readonly socket: net.Socket;
@@ -69,12 +73,9 @@ export class BrokerConnection {
69
73
 
70
74
  start(registration: unknown): Promise<void> {
71
75
  return new Promise((resolve, reject) => {
72
- this.registration = { resolve, reject };
73
- this.registrationTimer = setTimeout(
74
- () => this.failRegistration(new Error("Connection timeout")),
75
- 10000,
76
- );
77
- this.registrationTimer.unref();
76
+ const timer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
77
+ timer.unref();
78
+ this.phase = { kind: "registering", resolve, reject, timer };
78
79
  try {
79
80
  writeMessage(this.socket, registration);
80
81
  } catch (error) {
@@ -84,23 +85,28 @@ export class BrokerConnection {
84
85
  }
85
86
 
86
87
  confirmRegistration(): void {
87
- this.registered = true;
88
- const pending = this.registration;
89
- this.clearRegistration();
90
- pending?.resolve();
88
+ const pending = this.takeRegistration();
89
+ if (pending !== undefined) {
90
+ this.phase = { kind: "active" };
91
+ pending.resolve();
92
+ }
91
93
  }
92
94
 
93
- private clearRegistration(): void {
94
- if (this.registrationTimer) {
95
- clearTimeout(this.registrationTimer);
95
+ private takeRegistration() {
96
+ const pending = this.phase;
97
+ if (pending.kind !== "registering") {
98
+ return;
96
99
  }
97
- this.registrationTimer = null;
98
- this.registration = null;
100
+ clearTimeout(pending.timer);
101
+ this.phase = { kind: "acquired" };
102
+ return pending;
99
103
  }
100
104
 
101
105
  private failRegistration(error: Readonly<Error>): void {
102
- const pending = this.registration;
103
- this.clearRegistration();
106
+ const pending = this.takeRegistration();
107
+ if (this.phase.kind !== "closing") {
108
+ this.phase = { kind: "failed" };
109
+ }
104
110
  this.failure = error;
105
111
  this.socket.destroy();
106
112
  pending?.reject(error);
@@ -127,19 +133,23 @@ export class BrokerConnection {
127
133
  };
128
134
 
129
135
  private readonly onClose = (): void => {
130
- const pending = this.registration;
131
- this.clearRegistration();
136
+ const closing = this.phase.kind === "closing";
137
+ const pending = this.takeRegistration();
138
+ this.phase = { kind: "closed" };
132
139
  this.socket.off("data", this.reader);
133
140
  this.socket.off("error", this.onError);
134
141
  this.socket.off("close", this.onClose);
135
142
  pending?.reject(new Error("Connection closed before registration"));
136
- this.handlers.closed(this.failure ?? new Error("Client disconnected"), this.closing);
143
+ this.handlers.closed(this.failure ?? new Error("Client disconnected"), closing);
137
144
  };
138
145
 
146
+ private get registered(): boolean {
147
+ return this.phase.kind === "active" || (this.phase.kind === "closing" && this.phase.registered);
148
+ }
149
+
139
150
  isActive(): boolean {
140
151
  return (
141
- this.registered &&
142
- !this.closing &&
152
+ this.phase.kind === "active" &&
143
153
  !this.socket.destroyed &&
144
154
  !this.socket.writableEnded &&
145
155
  this.socket.writable
@@ -147,15 +157,18 @@ export class BrokerConnection {
147
157
  }
148
158
 
149
159
  disconnect(): Promise<void> {
150
- this.closing = true;
160
+ const phase = this.phase;
161
+ if (phase.kind === "closing") {
162
+ return phase.task;
163
+ }
164
+ if (phase.kind === "closed") {
165
+ return Promise.resolve();
166
+ }
167
+ const pending = this.takeRegistration();
168
+ pending?.reject(new Error("Client disconnected before registration"));
151
169
  this.failure = null;
152
- return new Promise((resolve) => {
153
- let settled = false;
170
+ const task = new Promise<void>((resolve) => {
154
171
  const finish = () => {
155
- if (settled) {
156
- return;
157
- }
158
- settled = true;
159
172
  clearTimeout(timeout);
160
173
  this.socket.off("close", finish);
161
174
  this.socket.off("error", onError);
@@ -169,12 +182,18 @@ export class BrokerConnection {
169
182
  }, 2000);
170
183
  this.socket.once("close", finish);
171
184
  this.socket.once("error", onError);
172
- try {
185
+ });
186
+ this.phase = { kind: "closing", task, registered: phase.kind === "active" };
187
+ try {
188
+ if (phase.kind === "active" || phase.kind === "registering") {
173
189
  writeMessage(this.socket, { type: "unregister" });
174
190
  this.socket.end();
175
- } catch {
191
+ } else {
176
192
  this.socket.destroy();
177
193
  }
178
- });
194
+ } catch {
195
+ this.socket.destroy();
196
+ }
197
+ return task;
179
198
  }
180
199
  }
@@ -90,10 +90,15 @@ class MessageBuffer {
90
90
  }
91
91
  }
92
92
 
93
+ type FrameState =
94
+ | { readonly kind: "header" }
95
+ | { readonly kind: "payload"; readonly length: number }
96
+ | { readonly kind: "failed" };
97
+
93
98
  /**
94
99
  * Create a message reader that handles partial reads.
95
100
  * Calls onMessage for each complete message received.
96
- * Protocol or handler errors are reported to onError so the caller can close the socket.
101
+ * Protocol or handler errors end reading and are reported so the caller can close the socket.
97
102
  */
98
103
  export function createMessageReader(
99
104
  onMessage: (msg: unknown) => void,
@@ -101,32 +106,39 @@ export function createMessageReader(
101
106
  limit = MAX_FRAME_SIZE_BYTES,
102
107
  ): (data: Buffer) => void {
103
108
  const buffer = new MessageBuffer();
104
- let length: number | undefined;
109
+ let state: FrameState = { kind: "header" };
110
+ const fail = (error: Readonly<Error>) => {
111
+ state = { kind: "failed" };
112
+ buffer.clear();
113
+ onError(error);
114
+ };
105
115
 
106
116
  return (data: Buffer) => {
117
+ if (state.kind === "failed") {
118
+ return;
119
+ }
107
120
  buffer.append(data);
108
121
 
109
- while (buffer.length >= (length ?? 4)) {
110
- if (length === undefined) {
111
- length = buffer.read(4).readUInt32BE(0);
122
+ while (buffer.length >= (state.kind === "payload" ? state.length : 4)) {
123
+ if (state.kind === "header") {
124
+ const length = buffer.read(4).readUInt32BE(0);
112
125
  if (length > limit) {
113
- onError(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
114
- buffer.clear();
115
- length = undefined;
126
+ fail(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
116
127
  return;
117
128
  }
129
+ state = { kind: "payload", length };
118
130
  continue;
119
131
  }
120
132
 
121
- const payload = buffer.read(length);
122
- length = undefined;
133
+ const payload = buffer.read(state.length);
134
+ state = { kind: "header" };
123
135
 
124
136
  let msg: unknown;
125
137
  try {
126
138
  msg = JSON.parse(payload.toString("utf-8"));
127
139
  } catch (error) {
128
140
  const message = errorMessage(error);
129
- onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
141
+ fail(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
130
142
  return;
131
143
  }
132
144
 
@@ -134,7 +146,7 @@ export function createMessageReader(
134
146
  onMessage(msg);
135
147
  } catch (error) {
136
148
  const message = errorMessage(error);
137
- onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
149
+ fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
138
150
  return;
139
151
  }
140
152
  }