@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.
- package/CHANGELOG.md +44 -0
- package/dist/agent/contracts.js +12 -3
- package/dist/agent/index.js +9 -9
- package/dist/agent/legacy.js +5 -5
- package/dist/agent/notifications.js +21 -14
- package/dist/agent/parent-check.js +38 -0
- package/dist/agent/process.js +45 -52
- package/dist/agent/runner.js +59 -64
- package/dist/agent/storage.js +16 -4
- package/dist/agent/view.js +1 -3
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +4 -26
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client-park.d.ts +1 -1
- package/dist/pi-intercom/broker/client-park.js +22 -6
- package/dist/pi-intercom/broker/client.d.ts +3 -2
- package/dist/pi-intercom/broker/client.js +104 -112
- package/dist/pi-intercom/broker/connection.d.ts +3 -5
- package/dist/pi-intercom/broker/connection.js +48 -33
- package/dist/pi-intercom/broker/framing.d.ts +1 -1
- package/dist/pi-intercom/broker/framing.js +19 -12
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- package/dist/pi-intercom/connection.js +5 -8
- package/dist/pi-intercom/inbound-journal.js +5 -2
- package/dist/shared/journal-frames.js +19 -24
- package/docs/development.md +6 -0
- package/package.json +1 -1
- package/src/agent/contracts.ts +12 -3
- package/src/agent/index.ts +10 -10
- package/src/agent/legacy.ts +5 -5
- package/src/agent/notifications.ts +35 -15
- package/src/agent/parent-check.ts +40 -0
- package/src/agent/process.ts +53 -55
- package/src/agent/runner.ts +65 -65
- package/src/agent/storage.ts +18 -5
- package/src/agent/view.ts +1 -4
- package/src/pi-intercom/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +5 -37
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client-park.ts +27 -6
- package/src/pi-intercom/broker/client.ts +127 -126
- package/src/pi-intercom/broker/connection.ts +58 -39
- package/src/pi-intercom/broker/framing.ts +24 -12
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- package/src/pi-intercom/connection.ts +9 -9
- package/src/pi-intercom/inbound-journal.ts +5 -2
- 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
|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
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.
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
>();
|
|
77
|
-
private readonly pendingLists = new
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
137
|
+
if (this.connection || this.phase.kind !== "idle") {
|
|
143
138
|
throw new Error("Already connected");
|
|
144
139
|
}
|
|
145
|
-
this.
|
|
140
|
+
const task = this.establish(session, requestedId);
|
|
141
|
+
this.phase = { kind: "connecting", task };
|
|
146
142
|
try {
|
|
147
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
return;
|
|
351
|
+
disconnect(): Promise<void> {
|
|
352
|
+
const phase = this.phase;
|
|
353
|
+
if (phase.kind === "closing") {
|
|
354
|
+
return phase.task;
|
|
352
355
|
}
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
reader.fail();
|
|
411
430
|
reject(error);
|
|
412
431
|
};
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
resolve
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
const pending = this.takeRegistration();
|
|
89
|
+
if (pending !== undefined) {
|
|
90
|
+
this.phase = { kind: "active" };
|
|
91
|
+
pending.resolve();
|
|
92
|
+
}
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
private
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
private takeRegistration() {
|
|
96
|
+
const pending = this.phase;
|
|
97
|
+
if (pending.kind !== "registering") {
|
|
98
|
+
return;
|
|
96
99
|
}
|
|
97
|
-
|
|
98
|
-
this.
|
|
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.
|
|
103
|
-
this.
|
|
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
|
|
131
|
-
this.
|
|
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"),
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
|
110
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
|
|
138
150
|
return;
|
|
139
151
|
}
|
|
140
152
|
}
|