@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
|
@@ -3,6 +3,7 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { writeMessage } from "./framing.js";
|
|
4
4
|
import { BrokerConnection, connectBrokerSocket } from "./connection.js";
|
|
5
5
|
import { SnapshotReader } from "./snapshot-reader.js";
|
|
6
|
+
import { PendingRequests } from "./pending-requests.js";
|
|
6
7
|
import { LastMessageRequests, lastRequest } from "./client-last.js";
|
|
7
8
|
import { ParkRequests } from "./client-park.js";
|
|
8
9
|
import { WakeMessages } from "./client-wake.js";
|
|
@@ -36,10 +37,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
36
37
|
wakes = new WakeMessages(this, () => this.requireActiveSocket());
|
|
37
38
|
guardedDeliverySupported = false;
|
|
38
39
|
lastRequests;
|
|
39
|
-
pendingSends = new
|
|
40
|
-
pendingLists = new
|
|
41
|
-
|
|
42
|
-
disconnecting = false;
|
|
40
|
+
pendingSends = new PendingRequests();
|
|
41
|
+
pendingLists = new PendingRequests();
|
|
42
|
+
phase = { kind: "idle" };
|
|
43
43
|
sendTimeoutMs;
|
|
44
44
|
listTimeoutMs;
|
|
45
45
|
constructor(options = {}) {
|
|
@@ -50,14 +50,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
50
50
|
}
|
|
51
51
|
failPending(error) {
|
|
52
52
|
this.lastRequests.fail(error);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
this.pendingSends.clear();
|
|
57
|
-
for (const pending of this.pendingLists.values()) {
|
|
58
|
-
pending.reject(error);
|
|
59
|
-
}
|
|
60
|
-
this.pendingLists.clear();
|
|
53
|
+
this.pendingSends.fail(error);
|
|
54
|
+
this.pendingLists.fail(error);
|
|
61
55
|
}
|
|
62
56
|
get sessionId() {
|
|
63
57
|
return this._sessionId;
|
|
@@ -70,11 +64,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
70
64
|
}
|
|
71
65
|
isConnected() {
|
|
72
66
|
return ((this._sessionId ?? "").length > 0 &&
|
|
73
|
-
|
|
67
|
+
this.phase.kind !== "closing" &&
|
|
74
68
|
this.connection?.isActive() === true);
|
|
75
69
|
}
|
|
76
70
|
requireActiveSocket() {
|
|
77
|
-
if (this.
|
|
71
|
+
if (this.phase.kind === "closing") {
|
|
78
72
|
throw new Error("Client disconnecting");
|
|
79
73
|
}
|
|
80
74
|
const connection = this.connection;
|
|
@@ -87,62 +81,72 @@ export class IntercomClient extends EventEmitter {
|
|
|
87
81
|
return connection.socket;
|
|
88
82
|
}
|
|
89
83
|
async connect(session, requestedId) {
|
|
90
|
-
if (this.connection || this.
|
|
84
|
+
if (this.connection || this.phase.kind !== "idle") {
|
|
91
85
|
throw new Error("Already connected");
|
|
92
86
|
}
|
|
93
|
-
this.
|
|
87
|
+
const task = this.establish(session, requestedId);
|
|
88
|
+
this.phase = { kind: "connecting", task };
|
|
94
89
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
},
|
|
101
|
-
closed: (error, expected) => {
|
|
102
|
-
if (this.connection !== connection) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const established = this._sessionId !== null;
|
|
106
|
-
this.connection = null;
|
|
107
|
-
this._sessionId = null;
|
|
108
|
-
this._topicsSupported = false;
|
|
109
|
-
this.parking.settle(false);
|
|
110
|
-
this.guardedDeliverySupported = false;
|
|
111
|
-
this.wakes.supported = false;
|
|
112
|
-
this.disconnecting = false;
|
|
113
|
-
this.failPending(error);
|
|
114
|
-
if (established && !expected) {
|
|
115
|
-
this.emit("disconnected", error);
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
this.connection = connection;
|
|
120
|
-
const { topics, subscriptions, ...identity } = session;
|
|
121
|
-
try {
|
|
122
|
-
await connection.start({
|
|
123
|
-
type: "register",
|
|
124
|
-
session: identity,
|
|
125
|
-
requestedId,
|
|
126
|
-
topicFrames: true,
|
|
127
|
-
lastMessages: true,
|
|
128
|
-
parking: true,
|
|
129
|
-
receiptReads: 1,
|
|
130
|
-
guardedDelivery: 1,
|
|
131
|
-
...this.wakes.registration(),
|
|
132
|
-
});
|
|
90
|
+
await task;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (this.phase.kind === "connecting" && this.phase.task === task) {
|
|
94
|
+
this.phase = { kind: this.isConnected() ? "connected" : "idle" };
|
|
133
95
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|
|
137
108
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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");
|
|
143
129
|
}
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
152
|
async restoreTopics(topics, subscriptions) {
|
|
@@ -240,12 +244,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
240
244
|
if (type !== "delivered" && typeof reason !== "string") {
|
|
241
245
|
throw new Error(`Invalid ${errorMessage(type)} message`);
|
|
242
246
|
}
|
|
243
|
-
const pending = this.pendingSends.
|
|
247
|
+
const pending = this.pendingSends.take(messageId);
|
|
244
248
|
// Late responses are harmless after the caller has timed out.
|
|
245
249
|
if (!pending) {
|
|
246
250
|
return;
|
|
247
251
|
}
|
|
248
|
-
this.pendingSends.delete(messageId);
|
|
249
252
|
pending.resolve({
|
|
250
253
|
id: messageId,
|
|
251
254
|
accepted: type !== "delivery_failed",
|
|
@@ -266,27 +269,42 @@ export class IntercomClient extends EventEmitter {
|
|
|
266
269
|
return;
|
|
267
270
|
}
|
|
268
271
|
if (typeof error === "string") {
|
|
269
|
-
this.pendingLists.
|
|
270
|
-
pending.reject(new Error(error));
|
|
272
|
+
this.pendingLists.take(requestId)?.reject(new Error(error));
|
|
271
273
|
return;
|
|
272
274
|
}
|
|
273
275
|
pending.reader.append(sessions, receipts);
|
|
274
276
|
if (more !== true) {
|
|
275
|
-
this.pendingLists.
|
|
276
|
-
pending.resolve(pending.reader.finish());
|
|
277
|
+
this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
280
|
park(on = true) {
|
|
280
281
|
return this.parking.request(() => this.requireActiveSocket(), on);
|
|
281
282
|
}
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
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) {
|
|
288
294
|
this.failPending(new Error("Client disconnected"));
|
|
289
|
-
|
|
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
|
+
}
|
|
290
308
|
}
|
|
291
309
|
listSessions() {
|
|
292
310
|
return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
|
|
@@ -319,8 +337,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
319
337
|
}
|
|
320
338
|
return new Promise((resolve, reject) => {
|
|
321
339
|
const requestId = randomUUID();
|
|
340
|
+
const reader = new SnapshotReader();
|
|
322
341
|
const wrappedResolve = (snapshot) => {
|
|
323
|
-
clearTimeout(timeout);
|
|
324
342
|
try {
|
|
325
343
|
// Commit confirmed subscriptions before handling a following message in the same read.
|
|
326
344
|
onAccepted?.(snapshot);
|
|
@@ -331,21 +349,14 @@ export class IntercomClient extends EventEmitter {
|
|
|
331
349
|
}
|
|
332
350
|
};
|
|
333
351
|
const wrappedReject = (error) => {
|
|
334
|
-
|
|
352
|
+
reader.fail();
|
|
335
353
|
reject(error);
|
|
336
354
|
};
|
|
337
|
-
const timeout = setTimeout(() => {
|
|
338
|
-
if (this.pendingLists.has(requestId)) {
|
|
339
|
-
this.pendingLists.delete(requestId);
|
|
340
|
-
wrappedReject(Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
|
|
341
|
-
}
|
|
342
|
-
}, this.listTimeoutMs);
|
|
343
|
-
timeout.unref();
|
|
344
355
|
this.pendingLists.set(requestId, {
|
|
345
356
|
resolve: wrappedResolve,
|
|
346
357
|
reject: wrappedReject,
|
|
347
|
-
reader
|
|
348
|
-
});
|
|
358
|
+
reader,
|
|
359
|
+
}, this.listTimeoutMs, () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
|
|
349
360
|
try {
|
|
350
361
|
writeMessage(socket, {
|
|
351
362
|
type: "list",
|
|
@@ -355,9 +366,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
355
366
|
});
|
|
356
367
|
}
|
|
357
368
|
catch (error) {
|
|
358
|
-
|
|
359
|
-
this.pendingLists.delete(requestId);
|
|
360
|
-
reject(toError(error));
|
|
369
|
+
this.pendingLists.take(requestId)?.reject(toError(error));
|
|
361
370
|
}
|
|
362
371
|
});
|
|
363
372
|
}
|
|
@@ -377,7 +386,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
377
386
|
return Promise.reject(toError(error));
|
|
378
387
|
}
|
|
379
388
|
const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
|
|
380
|
-
if (this.pendingSends.
|
|
389
|
+
if (this.pendingSends.get(messageId)) {
|
|
381
390
|
return Promise.reject(new Error("Message ID already has a pending send"));
|
|
382
391
|
}
|
|
383
392
|
const message = {
|
|
@@ -402,22 +411,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
402
411
|
return Promise.reject(new Error("Invalid message format"));
|
|
403
412
|
}
|
|
404
413
|
return new Promise((resolve, reject) => {
|
|
405
|
-
|
|
406
|
-
clearTimeout(timeout);
|
|
407
|
-
resolve(result);
|
|
408
|
-
};
|
|
409
|
-
const wrappedReject = (error) => {
|
|
410
|
-
clearTimeout(timeout);
|
|
411
|
-
reject(error);
|
|
412
|
-
};
|
|
413
|
-
const timeout = setTimeout(() => {
|
|
414
|
-
if (this.pendingSends.has(messageId)) {
|
|
415
|
-
this.pendingSends.delete(messageId);
|
|
416
|
-
wrappedReject(new Error("Send timeout"));
|
|
417
|
-
}
|
|
418
|
-
}, this.sendTimeoutMs);
|
|
419
|
-
timeout.unref();
|
|
420
|
-
this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
|
|
414
|
+
this.pendingSends.set(messageId, { resolve, reject }, this.sendTimeoutMs, () => new Error("Send timeout"));
|
|
421
415
|
try {
|
|
422
416
|
writeMessage(socket, {
|
|
423
417
|
type: "send",
|
|
@@ -426,14 +420,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
426
420
|
});
|
|
427
421
|
}
|
|
428
422
|
catch (error) {
|
|
429
|
-
|
|
430
|
-
this.pendingSends.delete(messageId);
|
|
431
|
-
reject(toError(error));
|
|
423
|
+
this.pendingSends.take(messageId)?.reject(toError(error));
|
|
432
424
|
}
|
|
433
425
|
});
|
|
434
426
|
}
|
|
435
427
|
updatePresence(updates) {
|
|
436
|
-
if (this.
|
|
428
|
+
if (this.phase.kind === "closing") {
|
|
437
429
|
return;
|
|
438
430
|
}
|
|
439
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
56
|
-
|
|
57
|
-
this.
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const pending = this.takeRegistration();
|
|
65
|
+
if (pending !== undefined) {
|
|
66
|
+
this.phase = { kind: "active" };
|
|
67
|
+
pending.resolve();
|
|
68
|
+
}
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
takeRegistration() {
|
|
71
|
+
const pending = this.phase;
|
|
72
|
+
if (pending.kind !== "registering") {
|
|
73
|
+
return;
|
|
75
74
|
}
|
|
76
|
-
|
|
77
|
-
this.
|
|
75
|
+
clearTimeout(pending.timer);
|
|
76
|
+
this.phase = { kind: "acquired" };
|
|
77
|
+
return pending;
|
|
78
78
|
}
|
|
79
79
|
failRegistration(error) {
|
|
80
|
-
const pending = this.
|
|
81
|
-
this.
|
|
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
|
|
106
|
-
this.
|
|
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"),
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -84,33 +84,40 @@ class MessageBuffer {
|
|
|
84
84
|
/**
|
|
85
85
|
* Create a message reader that handles partial reads.
|
|
86
86
|
* Calls onMessage for each complete message received.
|
|
87
|
-
* Protocol or handler errors are reported
|
|
87
|
+
* Protocol or handler errors end reading and are reported so the caller can close the socket.
|
|
88
88
|
*/
|
|
89
89
|
export function createMessageReader(onMessage, onError, limit = MAX_FRAME_SIZE_BYTES) {
|
|
90
90
|
const buffer = new MessageBuffer();
|
|
91
|
-
let
|
|
91
|
+
let state = { kind: "header" };
|
|
92
|
+
const fail = (error) => {
|
|
93
|
+
state = { kind: "failed" };
|
|
94
|
+
buffer.clear();
|
|
95
|
+
onError(error);
|
|
96
|
+
};
|
|
92
97
|
return (data) => {
|
|
98
|
+
if (state.kind === "failed") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
93
101
|
buffer.append(data);
|
|
94
|
-
while (buffer.length >= (length
|
|
95
|
-
if (
|
|
96
|
-
length = buffer.read(4).readUInt32BE(0);
|
|
102
|
+
while (buffer.length >= (state.kind === "payload" ? state.length : 4)) {
|
|
103
|
+
if (state.kind === "header") {
|
|
104
|
+
const length = buffer.read(4).readUInt32BE(0);
|
|
97
105
|
if (length > limit) {
|
|
98
|
-
|
|
99
|
-
buffer.clear();
|
|
100
|
-
length = undefined;
|
|
106
|
+
fail(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
|
|
101
107
|
return;
|
|
102
108
|
}
|
|
109
|
+
state = { kind: "payload", length };
|
|
103
110
|
continue;
|
|
104
111
|
}
|
|
105
|
-
const payload = buffer.read(length);
|
|
106
|
-
|
|
112
|
+
const payload = buffer.read(state.length);
|
|
113
|
+
state = { kind: "header" };
|
|
107
114
|
let msg;
|
|
108
115
|
try {
|
|
109
116
|
msg = JSON.parse(payload.toString("utf-8"));
|
|
110
117
|
}
|
|
111
118
|
catch (error) {
|
|
112
119
|
const message = errorMessage(error);
|
|
113
|
-
|
|
120
|
+
fail(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
|
|
114
121
|
return;
|
|
115
122
|
}
|
|
116
123
|
try {
|
|
@@ -118,7 +125,7 @@ export function createMessageReader(onMessage, onError, limit = MAX_FRAME_SIZE_B
|
|
|
118
125
|
}
|
|
119
126
|
catch (error) {
|
|
120
127
|
const message = errorMessage(error);
|
|
121
|
-
|
|
128
|
+
fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
|
|
122
129
|
return;
|
|
123
130
|
}
|
|
124
131
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface Request {
|
|
2
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
3
|
+
}
|
|
4
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
5
|
+
export declare class PendingRequests<T extends Request> {
|
|
6
|
+
private readonly pending;
|
|
7
|
+
get(id: string): T | undefined;
|
|
8
|
+
set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void;
|
|
9
|
+
take(id: string): T | undefined;
|
|
10
|
+
fail(error: Readonly<Error>): void;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
2
|
+
export class PendingRequests {
|
|
3
|
+
pending = new Map();
|
|
4
|
+
get(id) {
|
|
5
|
+
return this.pending.get(id)?.value;
|
|
6
|
+
}
|
|
7
|
+
set(id, value, timeoutMs, timeoutError) {
|
|
8
|
+
const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
|
|
9
|
+
timer.unref();
|
|
10
|
+
this.pending.set(id, { value, timer });
|
|
11
|
+
}
|
|
12
|
+
take(id) {
|
|
13
|
+
const pending = this.pending.get(id);
|
|
14
|
+
if (pending === undefined) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
this.pending.delete(id);
|
|
18
|
+
clearTimeout(pending.timer);
|
|
19
|
+
return pending.value;
|
|
20
|
+
}
|
|
21
|
+
fail(error) {
|
|
22
|
+
for (const id of this.pending.keys()) {
|
|
23
|
+
this.take(id)?.reject(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
2
|
+
/** Only broker-staged messages remain replaceable; forwarding ends this owner's authority. */
|
|
3
|
+
export class ReplaceDeliveries {
|
|
4
|
+
pending = new Map();
|
|
5
|
+
deliver;
|
|
6
|
+
constructor(deliver) {
|
|
7
|
+
this.deliver = deliver;
|
|
8
|
+
}
|
|
9
|
+
stage(fromId, toId, from, message) {
|
|
10
|
+
const key = `${fromId}\0${toId}\0${message.threadId ?? ""}`;
|
|
11
|
+
this.take(key);
|
|
12
|
+
const timer = setTimeout(() => this.flush(key), REPLACE_DELIVERY_DELAY_MS);
|
|
13
|
+
timer.unref();
|
|
14
|
+
this.pending.set(key, { fromId, toId, from, message, timer });
|
|
15
|
+
}
|
|
16
|
+
take(key) {
|
|
17
|
+
const pending = this.pending.get(key);
|
|
18
|
+
if (pending !== undefined) {
|
|
19
|
+
this.pending.delete(key);
|
|
20
|
+
clearTimeout(pending.timer);
|
|
21
|
+
}
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
flush(key) {
|
|
25
|
+
const pending = this.take(key);
|
|
26
|
+
if (pending !== undefined) {
|
|
27
|
+
this.deliver(pending.toId, pending.from, pending.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
disconnected(sessionId) {
|
|
31
|
+
for (const [key, pending] of this.pending) {
|
|
32
|
+
if (pending.toId === sessionId ||
|
|
33
|
+
(pending.fromId === sessionId && pending.message.expectsReply === true)) {
|
|
34
|
+
this.take(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type SessionSnapshot } from "../types.ts";
|
|
2
2
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
3
3
|
export declare class SnapshotReader {
|
|
4
|
-
private
|
|
5
|
-
private
|
|
4
|
+
private phase;
|
|
5
|
+
private open;
|
|
6
|
+
fail(): void;
|
|
6
7
|
private addSession;
|
|
7
8
|
append(sessions: readonly unknown[], receipts: unknown): void;
|
|
8
9
|
finish(): SessionSnapshot;
|