@fitchmultz/pi-subagents 0.52.20 → 0.52.22

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 (48) hide show
  1. package/CHANGELOG.md +46 -2
  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-wake-state.js +31 -6
  13. package/dist/pi-intercom/bridge-wake.js +4 -10
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  16. package/dist/pi-intercom/broker/client-park.js +22 -6
  17. package/dist/pi-intercom/broker/client.d.ts +3 -2
  18. package/dist/pi-intercom/broker/client.js +88 -62
  19. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  20. package/dist/pi-intercom/broker/connection.js +48 -33
  21. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  22. package/dist/pi-intercom/broker/framing.js +19 -12
  23. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  24. package/dist/pi-intercom/connection.js +5 -8
  25. package/dist/pi-intercom/inbound-journal.js +5 -2
  26. package/dist/shared/journal-frames.js +19 -24
  27. package/docs/development.md +6 -0
  28. package/package.json +1 -1
  29. package/src/agent/contracts.ts +12 -3
  30. package/src/agent/index.ts +10 -10
  31. package/src/agent/legacy.ts +5 -5
  32. package/src/agent/notifications.ts +35 -15
  33. package/src/agent/parent-check.ts +40 -0
  34. package/src/agent/process.ts +53 -55
  35. package/src/agent/runner.ts +65 -65
  36. package/src/agent/storage.ts +18 -5
  37. package/src/agent/view.ts +1 -4
  38. package/src/pi-intercom/bridge-wake-state.ts +39 -8
  39. package/src/pi-intercom/bridge-wake.ts +4 -9
  40. package/src/pi-intercom/broker/broker.ts +5 -37
  41. package/src/pi-intercom/broker/client-park.ts +27 -6
  42. package/src/pi-intercom/broker/client.ts +91 -61
  43. package/src/pi-intercom/broker/connection.ts +58 -39
  44. package/src/pi-intercom/broker/framing.ts +24 -12
  45. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  46. package/src/pi-intercom/connection.ts +9 -9
  47. package/src/pi-intercom/inbound-journal.ts +5 -2
  48. package/src/shared/journal-frames.ts +22 -23
@@ -118,13 +118,25 @@ export function latest(base, initial) {
118
118
  export function conversation(base, launch) {
119
119
  return runDirectory(base, launch.rootId);
120
120
  }
121
- export function enqueue(base, launch, action, options = {}) {
122
- const id = action === "run" ? launch.id : randomUUID();
121
+ /** Metadata only: replacing command bytes invalidates the actor's parsed-command cache. */
122
+ export function commandIdentity(file) {
123
+ try {
124
+ const stat = fs.statSync(file, { bigint: true });
125
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
126
+ }
127
+ catch (error) {
128
+ if (missing(error)) {
129
+ return;
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+ export function enqueue(base, launch, input) {
135
+ const id = input.action === "run" ? launch.id : randomUUID();
123
136
  const command = {
124
137
  id,
125
138
  runId: launch.id,
126
- action,
127
- ...options,
139
+ ...input,
128
140
  createdAt: Date.now(),
129
141
  };
130
142
  write(path.join(conversation(base, launch), "commands", `${id}.json`), command, true);
@@ -2,12 +2,10 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { readOutputPage } from "../shared/output-page.js";
4
4
  import { actor, alive, observeAlive } from "./process.js";
5
- import { admissionSchema, commandSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
5
+ import { admissionSchema, commandMetadataSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
6
6
  import { conversation, read, runDirectory } from "./storage.js";
7
7
  import { published } from "./notifications.js";
8
8
  import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
9
- import { Type } from "./typebox.js";
10
- const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
11
9
  function toolResult(value, text) {
12
10
  return { content: [{ type: "text", text }], details: value };
13
11
  }
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.20","sha256":"6698668f485ef551faf914031fee7ad7165c9062a38c31b2827077d8e93e94b9"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.22","sha256":"fc7e48d1fb96f143f56be9757d30a98bb138b94edd1f5388438ddeb3eed54638"});
@@ -28,7 +28,7 @@ export class WakeState {
28
28
  inode;
29
29
  device;
30
30
  owner;
31
- finishing = false;
31
+ phase = { kind: "idle" };
32
32
  constructor(stateDir) {
33
33
  this.stateDir = stateDir;
34
34
  this.path = join(stateDir, FILE);
@@ -194,13 +194,37 @@ export class WakeState {
194
194
  this.validate();
195
195
  this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
196
196
  }
197
- /** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
197
+ /** Persist quota and uncertainty before exposing a single-use POST reservation. */
198
198
  reserve(bucket) {
199
199
  this.retrySettlement();
200
- return this.transaction(() => this.reservation(bucket));
200
+ if (this.phase.kind !== "idle") {
201
+ throw new Error("A wake reservation is already active or its owner is closed.");
202
+ }
203
+ const until = this.transaction(() => this.reservation(bucket));
204
+ if (until !== undefined) {
205
+ return { kind: "deferred", until };
206
+ }
207
+ const reservation = {
208
+ kind: "reserved",
209
+ post: async (action) => {
210
+ if (this.phase !== reservation) {
211
+ throw new Error("This wake reservation is no longer available.");
212
+ }
213
+ this.phase = { kind: "posting" };
214
+ try {
215
+ await action();
216
+ }
217
+ finally {
218
+ // Completion, rejection and abort all start the durable expiry window here.
219
+ this.settle();
220
+ }
221
+ },
222
+ };
223
+ this.phase = reservation;
224
+ return reservation;
201
225
  }
202
226
  retrySettlement() {
203
- if (this.finishing) {
227
+ if (this.phase.kind === "settlementPending") {
204
228
  this.settle();
205
229
  }
206
230
  }
@@ -219,9 +243,9 @@ export class WakeState {
219
243
  }
220
244
  /** A failed settlement stays open and must be retried before this owner can dispatch again. */
221
245
  settle() {
222
- this.finishing = true;
246
+ this.phase = { kind: "settlementPending" };
223
247
  this.transaction(() => this.finishOwner(this.owner.name));
224
- this.finishing = false;
248
+ this.phase = { kind: "idle" };
225
249
  }
226
250
  finishOwner(owner) {
227
251
  const now = Math.max(Date.now(), integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock));
@@ -286,6 +310,7 @@ export class WakeState {
286
310
  this.settle();
287
311
  }
288
312
  finally {
313
+ this.phase = { kind: "closed" };
289
314
  this.db.close();
290
315
  this.owner.close();
291
316
  }
@@ -171,20 +171,14 @@ export class WakeDeliveries {
171
171
  next = Math.min(next, due);
172
172
  continue;
173
173
  }
174
- const deferred = state.reserve(key);
175
- if (deferred !== undefined) {
176
- next = Math.min(next, deferred);
174
+ const reservation = state.reserve(key);
175
+ if (reservation.kind === "deferred") {
176
+ next = Math.min(next, reservation.until);
177
177
  continue;
178
178
  }
179
179
  // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
180
180
  // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
181
- try {
182
- await this.post(candidate);
183
- }
184
- finally {
185
- // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
186
- state.settle();
187
- }
181
+ await reservation.post(() => this.post(candidate));
188
182
  // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
189
183
  return Date.now();
190
184
  }
@@ -14,13 +14,13 @@ import { guardedDeliveryError } from "./guard.js";
14
14
  import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
15
15
  import { errorMessage, isRecord } from "../../shared/unknown.js";
16
16
  import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
17
+ import { ReplaceDeliveries } from "./replace-deliveries.js";
17
18
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
18
- const REPLACE_DELIVERY_DELAY_MS = 1500;
19
19
  class IntercomBroker {
20
20
  sessions = new Map();
21
21
  lastMessages = new LastMessageRelay(this.sessions);
22
22
  parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
23
- pendingReplaceDeliveries = new Map();
23
+ replacements = new ReplaceDeliveries(this.deliverMessage.bind(this));
24
24
  server;
25
25
  shutdownTimer = null;
26
26
  socketPath;
@@ -153,7 +153,7 @@ class IntercomBroker {
153
153
  this.lastMessages.disconnected(sessionId);
154
154
  if (!this.parking.closed(session)) {
155
155
  this.sessions.delete(sessionId);
156
- this.clearPendingReplaceDeliveries(sessionId);
156
+ this.replacements.disconnected(sessionId);
157
157
  this.broadcast({ type: "session_left", sessionId }, session.info);
158
158
  }
159
159
  this.scheduleShutdownCheck();
@@ -348,25 +348,12 @@ class IntercomBroker {
348
348
  ...(comms ? { lastIntercomActivity: now } : {}),
349
349
  };
350
350
  }
351
- replaceKey(fromId, toId, threadId) {
352
- return `${fromId}\0${toId}\0${threadId}`;
353
- }
354
351
  queueReplaceDelivery(fromId, toId, from, message) {
355
352
  const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
356
353
  if (validationError !== null) {
357
354
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
358
355
  }
359
- const key = this.replaceKey(fromId, toId, message.threadId ?? "");
360
- const existing = this.pendingReplaceDeliveries.get(key);
361
- if (existing) {
362
- clearTimeout(existing.timer);
363
- }
364
- const timer = setTimeout(() => {
365
- this.pendingReplaceDeliveries.delete(key);
366
- this.deliverMessage(toId, from, message);
367
- }, REPLACE_DELIVERY_DELAY_MS);
368
- timer.unref();
369
- this.pendingReplaceDeliveries.set(key, { fromId, toId, message, timer });
356
+ this.replacements.stage(fromId, toId, from, message);
370
357
  return {
371
358
  id: message.id,
372
359
  accepted: true,
@@ -375,15 +362,6 @@ class IntercomBroker {
375
362
  reason: "Queued for replace-mode delivery",
376
363
  };
377
364
  }
378
- clearPendingReplaceDeliveries(sessionId) {
379
- for (const [key, pending] of this.pendingReplaceDeliveries) {
380
- if (pending.toId === sessionId ||
381
- (pending.fromId === sessionId && pending.message.expectsReply === true)) {
382
- clearTimeout(pending.timer);
383
- this.pendingReplaceDeliveries.delete(key);
384
- }
385
- }
386
- }
387
365
  /** One admission rule; retained input is checked as the uncompacted envelope any later
388
366
  * transport generation accepts. */
389
367
  validateDeliveryPayload(from, message, target) {
@@ -2,7 +2,7 @@ import type net from "node:net";
2
2
  /** Parking negotiation and its single outstanding broker acknowledgement. */
3
3
  export declare class ParkRequests {
4
4
  supported: boolean;
5
- private acknowledged?;
5
+ private state;
6
6
  settle(parked: boolean): void;
7
7
  /** True once the broker retains later input; false when it cannot or the socket closed. */
8
8
  request(socket: () => net.Socket, on: boolean): Promise<boolean>;
@@ -2,20 +2,36 @@ import { writeMessage } from "./framing.js";
2
2
  /** Parking negotiation and its single outstanding broker acknowledgement. */
3
3
  export class ParkRequests {
4
4
  supported = false;
5
- acknowledged;
5
+ state = { kind: "idle" };
6
6
  settle(parked) {
7
- this.acknowledged?.(parked);
7
+ const state = this.state;
8
+ this.state = { kind: "idle" };
9
+ if (state.kind === "pending") {
10
+ state.resolve(parked);
11
+ }
8
12
  }
9
13
  /** True once the broker retains later input; false when it cannot or the socket closed. */
10
14
  request(socket, on) {
11
- return new Promise((resolve) => {
12
- this.acknowledged = on && this.supported ? resolve : undefined;
15
+ if (on && this.state.kind === "pending") {
16
+ return this.state.promise;
17
+ }
18
+ const { promise, resolve, reject } = Promise.withResolvers();
19
+ this.settle(false);
20
+ if (on && this.supported) {
21
+ this.state = { kind: "pending", promise, resolve };
22
+ }
23
+ try {
13
24
  if (this.supported) {
14
25
  writeMessage(socket(), { type: on ? "park" : "unpark" });
15
26
  }
16
- if (this.acknowledged === undefined) {
27
+ if (this.state.kind === "idle") {
17
28
  resolve(false);
18
29
  }
19
- });
30
+ }
31
+ catch (error) {
32
+ this.state = { kind: "idle" };
33
+ reject(error);
34
+ }
35
+ return promise;
20
36
  }
21
37
  }
@@ -18,8 +18,7 @@ export declare class IntercomClient extends EventEmitter {
18
18
  private readonly lastRequests;
19
19
  private readonly pendingSends;
20
20
  private readonly pendingLists;
21
- private connecting;
22
- private disconnecting;
21
+ private phase;
23
22
  private readonly sendTimeoutMs;
24
23
  private readonly listTimeoutMs;
25
24
  constructor(options?: IntercomClientOptions);
@@ -30,6 +29,7 @@ export declare class IntercomClient extends EventEmitter {
30
29
  isConnected(): boolean;
31
30
  private requireActiveSocket;
32
31
  connect(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void>;
32
+ private establish;
33
33
  private restoreTopics;
34
34
  private handleBrokerMessage;
35
35
  private receiveRegistration;
@@ -39,6 +39,7 @@ export declare class IntercomClient extends EventEmitter {
39
39
  private receiveSessions;
40
40
  park(on?: boolean): Promise<boolean>;
41
41
  disconnect(): Promise<void>;
42
+ private close;
42
43
  listSessions(): Promise<SessionInfo[]>;
43
44
  last(to: string, query?: LastMessageQuery): Promise<LastMessageResult>;
44
45
  receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult>;
@@ -39,8 +39,7 @@ export class IntercomClient extends EventEmitter {
39
39
  lastRequests;
40
40
  pendingSends = new PendingRequests();
41
41
  pendingLists = new PendingRequests();
42
- connecting = false;
43
- disconnecting = false;
42
+ phase = { kind: "idle" };
44
43
  sendTimeoutMs;
45
44
  listTimeoutMs;
46
45
  constructor(options = {}) {
@@ -65,11 +64,11 @@ export class IntercomClient extends EventEmitter {
65
64
  }
66
65
  isConnected() {
67
66
  return ((this._sessionId ?? "").length > 0 &&
68
- !this.disconnecting &&
67
+ this.phase.kind !== "closing" &&
69
68
  this.connection?.isActive() === true);
70
69
  }
71
70
  requireActiveSocket() {
72
- if (this.disconnecting) {
71
+ if (this.phase.kind === "closing") {
73
72
  throw new Error("Client disconnecting");
74
73
  }
75
74
  const connection = this.connection;
@@ -82,62 +81,72 @@ export class IntercomClient extends EventEmitter {
82
81
  return connection.socket;
83
82
  }
84
83
  async connect(session, requestedId) {
85
- if (this.connection || this.connecting) {
84
+ if (this.connection || this.phase.kind !== "idle") {
86
85
  throw new Error("Already connected");
87
86
  }
88
- this.connecting = true;
87
+ const task = this.establish(session, requestedId);
88
+ this.phase = { kind: "connecting", task };
89
89
  try {
90
- const socket = await connectBrokerSocket();
91
- const connection = new BrokerConnection(socket, {
92
- message: (message) => this.handleBrokerMessage(message),
93
- error: (error) => {
94
- this.emit("error", error);
95
- },
96
- closed: (error, expected) => {
97
- if (this.connection !== connection) {
98
- return;
99
- }
100
- const established = this._sessionId !== null;
101
- this.connection = null;
102
- this._sessionId = null;
103
- this._topicsSupported = false;
104
- this.parking.settle(false);
105
- this.guardedDeliverySupported = false;
106
- this.wakes.supported = false;
107
- this.disconnecting = false;
108
- this.failPending(error);
109
- if (established && !expected) {
110
- this.emit("disconnected", error);
111
- }
112
- },
113
- });
114
- this.connection = connection;
115
- const { topics, subscriptions, ...identity } = session;
116
- try {
117
- await connection.start({
118
- type: "register",
119
- session: identity,
120
- requestedId,
121
- topicFrames: true,
122
- lastMessages: true,
123
- parking: true,
124
- receiptReads: 1,
125
- guardedDelivery: 1,
126
- ...this.wakes.registration(),
127
- });
90
+ await task;
91
+ }
92
+ finally {
93
+ if (this.phase.kind === "connecting" && this.phase.task === task) {
94
+ this.phase = { kind: this.isConnected() ? "connected" : "idle" };
128
95
  }
129
- catch (error) {
130
- if (this.connection === connection) {
131
- this.connection = null;
96
+ }
97
+ }
98
+ async establish(session, requestedId) {
99
+ const socket = await connectBrokerSocket();
100
+ const connection = new BrokerConnection(socket, {
101
+ message: (message) => this.handleBrokerMessage(message),
102
+ error: (error) => {
103
+ this.emit("error", error);
104
+ },
105
+ closed: (error, expected) => {
106
+ if (this.connection !== connection) {
107
+ return;
132
108
  }
133
- throw error;
134
- }
135
- if (this.supportsTopics) {
136
- await this.restoreTopics(topics, subscriptions);
137
- }
109
+ const established = this._sessionId !== null;
110
+ this.connection = null;
111
+ this._sessionId = null;
112
+ this._topicsSupported = false;
113
+ this.parking.settle(false);
114
+ this.guardedDeliverySupported = false;
115
+ this.wakes.supported = false;
116
+ if (this.phase.kind === "connected") {
117
+ this.phase = { kind: "idle" };
118
+ }
119
+ this.failPending(error);
120
+ if (established && !expected) {
121
+ this.emit("disconnected", error);
122
+ }
123
+ },
124
+ });
125
+ this.connection = connection;
126
+ if (this.phase.kind === "closing") {
127
+ await connection.disconnect();
128
+ throw new Error("Client disconnecting");
138
129
  }
139
- finally {
140
- this.connecting = false;
130
+ const { topics, subscriptions, ...identity } = session;
131
+ try {
132
+ await connection.start({
133
+ type: "register",
134
+ session: identity,
135
+ requestedId,
136
+ topicFrames: true,
137
+ lastMessages: true,
138
+ parking: true,
139
+ receiptReads: 1,
140
+ guardedDelivery: 1,
141
+ ...this.wakes.registration(),
142
+ });
143
+ }
144
+ catch (error) {
145
+ await connection.disconnect();
146
+ throw error;
147
+ }
148
+ if (this.supportsTopics) {
149
+ await this.restoreTopics(topics, subscriptions);
141
150
  }
142
151
  }
143
152
  async restoreTopics(topics, subscriptions) {
@@ -271,14 +280,31 @@ export class IntercomClient extends EventEmitter {
271
280
  park(on = true) {
272
281
  return this.parking.request(() => this.requireActiveSocket(), on);
273
282
  }
274
- async disconnect() {
275
- const connection = this.connection;
276
- if (!connection) {
277
- return;
278
- }
279
- this.disconnecting = true;
283
+ disconnect() {
284
+ const phase = this.phase;
285
+ if (phase.kind === "closing") {
286
+ return phase.task;
287
+ }
288
+ const connecting = phase.kind === "connecting" ? phase.task : undefined;
289
+ const task = Promise.resolve().then(() => this.close(connecting));
290
+ this.phase = { kind: "closing", task };
291
+ return task;
292
+ }
293
+ async close(connecting) {
280
294
  this.failPending(new Error("Client disconnected"));
281
- await connection.disconnect();
295
+ try {
296
+ await this.connection?.disconnect();
297
+ try {
298
+ await connecting;
299
+ }
300
+ catch {
301
+ // The connect caller owns acquisition failure; close still joins any acquired socket.
302
+ }
303
+ await this.connection?.disconnect();
304
+ }
305
+ finally {
306
+ this.phase = { kind: "idle" };
307
+ }
282
308
  }
283
309
  listSessions() {
284
310
  return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
@@ -399,7 +425,7 @@ export class IntercomClient extends EventEmitter {
399
425
  });
400
426
  }
401
427
  updatePresence(updates) {
402
- if (this.disconnecting) {
428
+ if (this.phase.kind === "closing") {
403
429
  return;
404
430
  }
405
431
  const socket = this.connection?.socket;
@@ -7,22 +7,20 @@ interface ConnectionHandlers {
7
7
  }
8
8
  /** One socket owns registration, read/error listeners, and graceful shutdown. */
9
9
  export declare class BrokerConnection {
10
- private registered;
11
- private closing;
10
+ private phase;
12
11
  private failure;
13
- private registration;
14
- private registrationTimer;
15
12
  private readonly reader;
16
13
  readonly socket: net.Socket;
17
14
  private readonly handlers;
18
15
  constructor(socket: net.Socket, handlers: ConnectionHandlers);
19
16
  start(registration: unknown): Promise<void>;
20
17
  confirmRegistration(): void;
21
- private clearRegistration;
18
+ private takeRegistration;
22
19
  private failRegistration;
23
20
  private protocolError;
24
21
  private readonly onError;
25
22
  private readonly onClose;
23
+ private get registered();
26
24
  isActive(): boolean;
27
25
  disconnect(): Promise<void>;
28
26
  }
@@ -34,11 +34,8 @@ export async function connectBrokerSocket() {
34
34
  }
35
35
  /** One socket owns registration, read/error listeners, and graceful shutdown. */
36
36
  export class BrokerConnection {
37
- registered = false;
38
- closing = false;
37
+ phase = { kind: "acquired" };
39
38
  failure = null;
40
- registration = null;
41
- registrationTimer = null;
42
39
  reader;
43
40
  socket;
44
41
  handlers;
@@ -52,9 +49,9 @@ export class BrokerConnection {
52
49
  }
53
50
  start(registration) {
54
51
  return new Promise((resolve, reject) => {
55
- this.registration = { resolve, reject };
56
- this.registrationTimer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
57
- this.registrationTimer.unref();
52
+ const timer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
53
+ timer.unref();
54
+ this.phase = { kind: "registering", resolve, reject, timer };
58
55
  try {
59
56
  writeMessage(this.socket, registration);
60
57
  }
@@ -64,21 +61,26 @@ export class BrokerConnection {
64
61
  });
65
62
  }
66
63
  confirmRegistration() {
67
- this.registered = true;
68
- const pending = this.registration;
69
- this.clearRegistration();
70
- pending?.resolve();
64
+ const pending = this.takeRegistration();
65
+ if (pending !== undefined) {
66
+ this.phase = { kind: "active" };
67
+ pending.resolve();
68
+ }
71
69
  }
72
- clearRegistration() {
73
- if (this.registrationTimer) {
74
- clearTimeout(this.registrationTimer);
70
+ takeRegistration() {
71
+ const pending = this.phase;
72
+ if (pending.kind !== "registering") {
73
+ return;
75
74
  }
76
- this.registrationTimer = null;
77
- this.registration = null;
75
+ clearTimeout(pending.timer);
76
+ this.phase = { kind: "acquired" };
77
+ return pending;
78
78
  }
79
79
  failRegistration(error) {
80
- const pending = this.registration;
81
- this.clearRegistration();
80
+ const pending = this.takeRegistration();
81
+ if (this.phase.kind !== "closing") {
82
+ this.phase = { kind: "failed" };
83
+ }
82
84
  this.failure = error;
83
85
  this.socket.destroy();
84
86
  pending?.reject(error);
@@ -102,31 +104,37 @@ export class BrokerConnection {
102
104
  this.handlers.error(error);
103
105
  };
104
106
  onClose = () => {
105
- const pending = this.registration;
106
- this.clearRegistration();
107
+ const closing = this.phase.kind === "closing";
108
+ const pending = this.takeRegistration();
109
+ this.phase = { kind: "closed" };
107
110
  this.socket.off("data", this.reader);
108
111
  this.socket.off("error", this.onError);
109
112
  this.socket.off("close", this.onClose);
110
113
  pending?.reject(new Error("Connection closed before registration"));
111
- this.handlers.closed(this.failure ?? new Error("Client disconnected"), this.closing);
114
+ this.handlers.closed(this.failure ?? new Error("Client disconnected"), closing);
112
115
  };
116
+ get registered() {
117
+ return this.phase.kind === "active" || (this.phase.kind === "closing" && this.phase.registered);
118
+ }
113
119
  isActive() {
114
- return (this.registered &&
115
- !this.closing &&
120
+ return (this.phase.kind === "active" &&
116
121
  !this.socket.destroyed &&
117
122
  !this.socket.writableEnded &&
118
123
  this.socket.writable);
119
124
  }
120
125
  disconnect() {
121
- this.closing = true;
126
+ const phase = this.phase;
127
+ if (phase.kind === "closing") {
128
+ return phase.task;
129
+ }
130
+ if (phase.kind === "closed") {
131
+ return Promise.resolve();
132
+ }
133
+ const pending = this.takeRegistration();
134
+ pending?.reject(new Error("Client disconnected before registration"));
122
135
  this.failure = null;
123
- return new Promise((resolve) => {
124
- let settled = false;
136
+ const task = new Promise((resolve) => {
125
137
  const finish = () => {
126
- if (settled) {
127
- return;
128
- }
129
- settled = true;
130
138
  clearTimeout(timeout);
131
139
  this.socket.off("close", finish);
132
140
  this.socket.off("error", onError);
@@ -140,13 +148,20 @@ export class BrokerConnection {
140
148
  }, 2000);
141
149
  this.socket.once("close", finish);
142
150
  this.socket.once("error", onError);
143
- try {
151
+ });
152
+ this.phase = { kind: "closing", task, registered: phase.kind === "active" };
153
+ try {
154
+ if (phase.kind === "active" || phase.kind === "registering") {
144
155
  writeMessage(this.socket, { type: "unregister" });
145
156
  this.socket.end();
146
157
  }
147
- catch {
158
+ else {
148
159
  this.socket.destroy();
149
160
  }
150
- });
161
+ }
162
+ catch {
163
+ this.socket.destroy();
164
+ }
165
+ return task;
151
166
  }
152
167
  }
@@ -12,6 +12,6 @@ export declare function writeFrame(socket: Socket, json: string, limit?: number)
12
12
  /**
13
13
  * Create a message reader that handles partial reads.
14
14
  * Calls onMessage for each complete message received.
15
- * Protocol or handler errors are reported to onError so the caller can close the socket.
15
+ * Protocol or handler errors end reading and are reported so the caller can close the socket.
16
16
  */
17
17
  export declare function createMessageReader(onMessage: (msg: unknown) => void, onError: (error: Readonly<Error>) => void, limit?: number): (data: Buffer) => void;