@fitchmultz/pi-subagents 0.52.19 → 0.52.20
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 +16 -0
- 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/client-last.js +6 -18
- package/dist/pi-intercom/broker/client.js +16 -50
- 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/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- package/package.json +1 -1
- package/src/pi-intercom/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client.ts +36 -65
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## 0.52.20 - 2026-11-07
|
|
6
|
+
|
|
7
|
+
<!-- npm-release:unreleased:start -->
|
|
8
|
+
<!-- npm-release:unreleased:end -->
|
|
9
|
+
|
|
10
|
+
- refactor: centralize request settlement and snapshot phases (#354) ([335c7e2](https://github.com/fitchmultz/pi-subagents/commit/335c7e269936467672f7e8d70cc67bf875a0a1c6))
|
|
11
|
+
|
|
12
|
+
### What's Changed
|
|
13
|
+
|
|
14
|
+
- refactor: centralize broker request settlement by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/354
|
|
15
|
+
- refactor: make bridge scheduler ownership explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/355
|
|
16
|
+
|
|
17
|
+
**Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.19...v0.52.20
|
|
18
|
+
|
|
5
19
|
## 0.52.19 - 2026-11-07
|
|
6
20
|
|
|
7
21
|
<!-- npm-release:unreleased:start -->
|
|
@@ -12,8 +26,10 @@
|
|
|
12
26
|
- Bind parent attachment to explicit detached, waiting, attaching and attached phases; managed readiness attaches only its own still-current waiting binding and shutdown joins admitted publication before releasing the matching generation.
|
|
13
27
|
- Add optional `delegate.thinking` to select child effort independently of the parent on all supported hosts, including official Pi 1.5.0. Omission keeps inherited effort. Native model suffixes must agree with an explicit field. Pi still clamps effort to model capabilities; guidance and saved continuations retain the child's saved effort.
|
|
14
28
|
- Represent child accounting as unrecorded, verified, pending or unavailable while keeping execution status and saved result formats unchanged.
|
|
29
|
+
- Give bridge wake and presence schedulers explicit timer, running, and stopped ownership while retaining durable wake policy.
|
|
15
30
|
- Make directory-watch availability and the coalesced wake's single-owner contract explicit without changing polling fallback.
|
|
16
31
|
- Preserve stranded last-good builds until a complete replacement publishes, and report retained recovery paths when a later build fails.
|
|
32
|
+
- Centralize broker request settlement and close multipart snapshot readers after completion or failure.
|
|
17
33
|
- Give prepared topic transactions action-specific payloads while preserving validate-before-commit and stale-restore normalization.
|
|
18
34
|
|
|
19
35
|
<!-- npm-release:unreleased:end -->
|
package/dist/build-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.20","sha256":"6698668f485ef551faf914031fee7ad7165c9062a38c31b2827077d8e93e94b9"});
|
|
@@ -4,9 +4,7 @@ import { readBridgeFile, writeBridgeJson } from "./bridge-files.js";
|
|
|
4
4
|
import { log } from "./bridge-protocol.js";
|
|
5
5
|
export class PersistentPresence {
|
|
6
6
|
registrations = new Map();
|
|
7
|
-
|
|
8
|
-
timer;
|
|
9
|
-
running;
|
|
7
|
+
phase = { kind: "idle" };
|
|
10
8
|
owner;
|
|
11
9
|
constructor(owner) {
|
|
12
10
|
this.owner = owner;
|
|
@@ -61,21 +59,28 @@ export class PersistentPresence {
|
|
|
61
59
|
this.start();
|
|
62
60
|
}
|
|
63
61
|
start() {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
73
|
-
}, 0);
|
|
62
|
+
if (this.phase.kind === "running" || this.phase.kind === "stopped") {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.schedule(0);
|
|
66
|
+
}
|
|
67
|
+
schedule(delay) {
|
|
68
|
+
if (this.phase.kind === "scheduled") {
|
|
69
|
+
clearTimeout(this.phase.timer);
|
|
74
70
|
}
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
const task = this.maintain().finally(() => {
|
|
73
|
+
if (this.phase.kind !== "stopped") {
|
|
74
|
+
this.schedule(15000);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
this.phase = { kind: "running", task };
|
|
78
|
+
}, delay);
|
|
79
|
+
this.phase = { kind: "scheduled", timer };
|
|
75
80
|
}
|
|
76
81
|
async maintain() {
|
|
77
82
|
for (const registration of this.registrations.values()) {
|
|
78
|
-
if (this.stopped) {
|
|
83
|
+
if (this.phase.kind === "stopped") {
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
81
86
|
const identity = this.owner.clients().get(registration.fingerprint);
|
|
@@ -95,9 +100,16 @@ export class PersistentPresence {
|
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
stop() {
|
|
104
|
+
const phase = this.phase;
|
|
105
|
+
if (phase.kind === "stopped") {
|
|
106
|
+
return phase.task;
|
|
107
|
+
}
|
|
108
|
+
if (phase.kind === "scheduled") {
|
|
109
|
+
clearTimeout(phase.timer);
|
|
110
|
+
}
|
|
111
|
+
const task = phase.kind === "running" ? phase.task : Promise.resolve();
|
|
112
|
+
this.phase = { kind: "stopped", task };
|
|
113
|
+
return task;
|
|
102
114
|
}
|
|
103
115
|
}
|
|
@@ -22,9 +22,7 @@ export class WakeDeliveries {
|
|
|
22
22
|
allowed;
|
|
23
23
|
deliveries = new Map();
|
|
24
24
|
controller = new AbortController();
|
|
25
|
-
|
|
26
|
-
running;
|
|
27
|
-
dirty = false;
|
|
25
|
+
phase = { kind: "idle" };
|
|
28
26
|
storageBackoff = 15000;
|
|
29
27
|
constructor(stateDir, allowed) {
|
|
30
28
|
this.stateDir = stateDir;
|
|
@@ -62,11 +60,17 @@ export class WakeDeliveries {
|
|
|
62
60
|
catch {
|
|
63
61
|
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
64
62
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const phase = this.phase;
|
|
64
|
+
switch (phase.kind) {
|
|
65
|
+
case "running":
|
|
66
|
+
this.phase = { kind: "running-dirty", task: phase.task };
|
|
67
|
+
return;
|
|
68
|
+
case "running-dirty":
|
|
69
|
+
case "stopped":
|
|
70
|
+
return;
|
|
71
|
+
case "idle":
|
|
72
|
+
case "scheduled":
|
|
73
|
+
this.schedule(Date.now());
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
reconcile() {
|
|
@@ -80,28 +84,36 @@ export class WakeDeliveries {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
schedule(deadline) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
if (this.phase.kind === "scheduled") {
|
|
88
|
+
clearTimeout(this.phase.timer);
|
|
89
|
+
}
|
|
90
|
+
const timer = setTimeout(() => this.startDelivery(), Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
|
|
91
|
+
this.phase = { kind: "scheduled", timer };
|
|
92
|
+
}
|
|
93
|
+
startDelivery() {
|
|
94
|
+
let next = Infinity;
|
|
95
|
+
const task = this.deliver()
|
|
96
|
+
.then((due) => {
|
|
97
|
+
next = due;
|
|
98
|
+
this.storageBackoff = 15000;
|
|
99
|
+
return;
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {
|
|
102
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
103
|
+
next = Date.now() + this.storageBackoff;
|
|
104
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
105
|
+
})
|
|
106
|
+
.finally(() => {
|
|
107
|
+
if (this.phase.kind === "stopped") {
|
|
91
108
|
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
this.
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
101
|
-
this.schedule(this.dirty ? Date.now() : next);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
}, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
|
|
109
|
+
}
|
|
110
|
+
const dirty = this.phase.kind === "running-dirty";
|
|
111
|
+
this.phase = { kind: "idle" };
|
|
112
|
+
if (dirty || Number.isFinite(next)) {
|
|
113
|
+
this.schedule(dirty ? Date.now() : next);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
this.phase = { kind: "running", task };
|
|
105
117
|
}
|
|
106
118
|
authorized(delivery) {
|
|
107
119
|
return (!this.controller.signal.aborted &&
|
|
@@ -233,14 +245,25 @@ export class WakeDeliveries {
|
|
|
233
245
|
}
|
|
234
246
|
this.changed(false);
|
|
235
247
|
}
|
|
236
|
-
|
|
237
|
-
this.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
stop() {
|
|
249
|
+
const phase = this.phase;
|
|
250
|
+
if (phase.kind === "stopped") {
|
|
251
|
+
return phase.task;
|
|
252
|
+
}
|
|
253
|
+
if (phase.kind === "scheduled") {
|
|
254
|
+
clearTimeout(phase.timer);
|
|
242
255
|
}
|
|
243
|
-
|
|
244
|
-
|
|
256
|
+
const running = phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
|
|
257
|
+
const task = Promise.resolve(running).then(() => {
|
|
258
|
+
for (const delivery of this.deliveries.values()) {
|
|
259
|
+
delivery.mailbox.close();
|
|
260
|
+
}
|
|
261
|
+
this.deliveries.clear();
|
|
262
|
+
this.state?.close();
|
|
263
|
+
return;
|
|
264
|
+
});
|
|
265
|
+
this.phase = { kind: "stopped", task };
|
|
266
|
+
this.controller.abort();
|
|
267
|
+
return task;
|
|
245
268
|
}
|
|
246
269
|
}
|
|
@@ -3,6 +3,7 @@ import { errorMessage } from "../../shared/unknown.js";
|
|
|
3
3
|
import { isNativeReadQuery, isNativeReadResult, isLastMessageResult, isReceiptResult, } from "./last.js";
|
|
4
4
|
import { normalizeSessionInfo, } from "../types.js";
|
|
5
5
|
import { writeMessage } from "./framing.js";
|
|
6
|
+
import { PendingRequests } from "./pending-requests.js";
|
|
6
7
|
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
7
8
|
export function lastRequest(msg) {
|
|
8
9
|
const from = normalizeSessionInfo(msg.from);
|
|
@@ -24,26 +25,20 @@ export class LastMessageRequests {
|
|
|
24
25
|
constructor(timeoutMs) {
|
|
25
26
|
this.timeoutMs = timeoutMs;
|
|
26
27
|
}
|
|
27
|
-
pending = new
|
|
28
|
+
pending = new PendingRequests();
|
|
28
29
|
fail(error) {
|
|
29
30
|
this.lastMessages = false;
|
|
30
31
|
this.receiptReads = false;
|
|
31
|
-
|
|
32
|
-
clearTimeout(pending.timer);
|
|
33
|
-
pending.reject(error);
|
|
34
|
-
}
|
|
35
|
-
this.pending.clear();
|
|
32
|
+
this.pending.fail(error);
|
|
36
33
|
}
|
|
37
34
|
receive(message) {
|
|
38
35
|
if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
|
|
39
36
|
throw new Error("Invalid last-message response");
|
|
40
37
|
}
|
|
41
|
-
const pending = this.pending.
|
|
38
|
+
const pending = this.pending.take(message.requestId);
|
|
42
39
|
if (!pending) {
|
|
43
40
|
return;
|
|
44
41
|
}
|
|
45
|
-
clearTimeout(pending.timer);
|
|
46
|
-
this.pending.delete(message.requestId);
|
|
47
42
|
pending.resolve(message.result);
|
|
48
43
|
}
|
|
49
44
|
async last(socket, to, query) {
|
|
@@ -66,19 +61,12 @@ export class LastMessageRequests {
|
|
|
66
61
|
}
|
|
67
62
|
return new Promise((resolve, reject) => {
|
|
68
63
|
const requestId = randomUUID();
|
|
69
|
-
|
|
70
|
-
this.pending.delete(requestId);
|
|
71
|
-
reject(new Error("Last-message read timeout"));
|
|
72
|
-
}, this.timeoutMs);
|
|
73
|
-
timer.unref();
|
|
74
|
-
this.pending.set(requestId, { resolve, reject, timer });
|
|
64
|
+
this.pending.set(requestId, { resolve, reject }, this.timeoutMs, () => new Error("Last-message read timeout"));
|
|
75
65
|
try {
|
|
76
66
|
writeMessage(socket, { type: "last_request", requestId, to, query });
|
|
77
67
|
}
|
|
78
68
|
catch (error) {
|
|
79
|
-
|
|
80
|
-
this.pending.delete(requestId);
|
|
81
|
-
reject(new Error(errorMessage(error)));
|
|
69
|
+
this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
|
|
82
70
|
}
|
|
83
71
|
});
|
|
84
72
|
}
|
|
@@ -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,8 +37,8 @@ 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
|
|
40
|
+
pendingSends = new PendingRequests();
|
|
41
|
+
pendingLists = new PendingRequests();
|
|
41
42
|
connecting = false;
|
|
42
43
|
disconnecting = false;
|
|
43
44
|
sendTimeoutMs;
|
|
@@ -50,14 +51,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
50
51
|
}
|
|
51
52
|
failPending(error) {
|
|
52
53
|
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();
|
|
54
|
+
this.pendingSends.fail(error);
|
|
55
|
+
this.pendingLists.fail(error);
|
|
61
56
|
}
|
|
62
57
|
get sessionId() {
|
|
63
58
|
return this._sessionId;
|
|
@@ -240,12 +235,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
240
235
|
if (type !== "delivered" && typeof reason !== "string") {
|
|
241
236
|
throw new Error(`Invalid ${errorMessage(type)} message`);
|
|
242
237
|
}
|
|
243
|
-
const pending = this.pendingSends.
|
|
238
|
+
const pending = this.pendingSends.take(messageId);
|
|
244
239
|
// Late responses are harmless after the caller has timed out.
|
|
245
240
|
if (!pending) {
|
|
246
241
|
return;
|
|
247
242
|
}
|
|
248
|
-
this.pendingSends.delete(messageId);
|
|
249
243
|
pending.resolve({
|
|
250
244
|
id: messageId,
|
|
251
245
|
accepted: type !== "delivery_failed",
|
|
@@ -266,14 +260,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
266
260
|
return;
|
|
267
261
|
}
|
|
268
262
|
if (typeof error === "string") {
|
|
269
|
-
this.pendingLists.
|
|
270
|
-
pending.reject(new Error(error));
|
|
263
|
+
this.pendingLists.take(requestId)?.reject(new Error(error));
|
|
271
264
|
return;
|
|
272
265
|
}
|
|
273
266
|
pending.reader.append(sessions, receipts);
|
|
274
267
|
if (more !== true) {
|
|
275
|
-
this.pendingLists.
|
|
276
|
-
pending.resolve(pending.reader.finish());
|
|
268
|
+
this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
|
|
277
269
|
}
|
|
278
270
|
}
|
|
279
271
|
park(on = true) {
|
|
@@ -319,8 +311,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
319
311
|
}
|
|
320
312
|
return new Promise((resolve, reject) => {
|
|
321
313
|
const requestId = randomUUID();
|
|
314
|
+
const reader = new SnapshotReader();
|
|
322
315
|
const wrappedResolve = (snapshot) => {
|
|
323
|
-
clearTimeout(timeout);
|
|
324
316
|
try {
|
|
325
317
|
// Commit confirmed subscriptions before handling a following message in the same read.
|
|
326
318
|
onAccepted?.(snapshot);
|
|
@@ -331,21 +323,14 @@ export class IntercomClient extends EventEmitter {
|
|
|
331
323
|
}
|
|
332
324
|
};
|
|
333
325
|
const wrappedReject = (error) => {
|
|
334
|
-
|
|
326
|
+
reader.fail();
|
|
335
327
|
reject(error);
|
|
336
328
|
};
|
|
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
329
|
this.pendingLists.set(requestId, {
|
|
345
330
|
resolve: wrappedResolve,
|
|
346
331
|
reject: wrappedReject,
|
|
347
|
-
reader
|
|
348
|
-
});
|
|
332
|
+
reader,
|
|
333
|
+
}, this.listTimeoutMs, () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
|
|
349
334
|
try {
|
|
350
335
|
writeMessage(socket, {
|
|
351
336
|
type: "list",
|
|
@@ -355,9 +340,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
355
340
|
});
|
|
356
341
|
}
|
|
357
342
|
catch (error) {
|
|
358
|
-
|
|
359
|
-
this.pendingLists.delete(requestId);
|
|
360
|
-
reject(toError(error));
|
|
343
|
+
this.pendingLists.take(requestId)?.reject(toError(error));
|
|
361
344
|
}
|
|
362
345
|
});
|
|
363
346
|
}
|
|
@@ -377,7 +360,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
377
360
|
return Promise.reject(toError(error));
|
|
378
361
|
}
|
|
379
362
|
const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
|
|
380
|
-
if (this.pendingSends.
|
|
363
|
+
if (this.pendingSends.get(messageId)) {
|
|
381
364
|
return Promise.reject(new Error("Message ID already has a pending send"));
|
|
382
365
|
}
|
|
383
366
|
const message = {
|
|
@@ -402,22 +385,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
402
385
|
return Promise.reject(new Error("Invalid message format"));
|
|
403
386
|
}
|
|
404
387
|
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 });
|
|
388
|
+
this.pendingSends.set(messageId, { resolve, reject }, this.sendTimeoutMs, () => new Error("Send timeout"));
|
|
421
389
|
try {
|
|
422
390
|
writeMessage(socket, {
|
|
423
391
|
type: "send",
|
|
@@ -426,9 +394,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
426
394
|
});
|
|
427
395
|
}
|
|
428
396
|
catch (error) {
|
|
429
|
-
|
|
430
|
-
this.pendingSends.delete(messageId);
|
|
431
|
-
reject(toError(error));
|
|
397
|
+
this.pendingSends.take(messageId)?.reject(toError(error));
|
|
432
398
|
}
|
|
433
399
|
});
|
|
434
400
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -48,15 +48,25 @@ function appendRows(previous, rows, supplied) {
|
|
|
48
48
|
}
|
|
49
49
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
50
50
|
export class SnapshotReader {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
phase = { kind: "open", sessions: new Map(), receipts: [] };
|
|
52
|
+
open() {
|
|
53
|
+
if (this.phase.kind !== "open") {
|
|
54
|
+
throw new Error(`Session snapshot is ${this.phase.kind}`);
|
|
55
|
+
}
|
|
56
|
+
return this.phase;
|
|
57
|
+
}
|
|
58
|
+
fail() {
|
|
59
|
+
if (this.phase.kind === "open") {
|
|
60
|
+
this.phase = { kind: "failed" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
addSession(state, row) {
|
|
54
64
|
if (!isRecord(row) || typeof row.id !== "string") {
|
|
55
65
|
throw new Error("Invalid session snapshot row");
|
|
56
66
|
}
|
|
57
67
|
const topics = optionalArray(row.topics);
|
|
58
68
|
const subscriptions = optionalArray(row.subscriptions);
|
|
59
|
-
const previous =
|
|
69
|
+
const previous = state.sessions.get(row.id);
|
|
60
70
|
const info = normalizeSessionInfo({
|
|
61
71
|
...previous?.info,
|
|
62
72
|
...row,
|
|
@@ -67,33 +77,42 @@ export class SnapshotReader {
|
|
|
67
77
|
throw new Error("Invalid session snapshot");
|
|
68
78
|
}
|
|
69
79
|
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
70
|
-
|
|
80
|
+
state.sessions.set(info.id, {
|
|
71
81
|
info: identity,
|
|
72
82
|
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
73
83
|
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
74
84
|
});
|
|
75
85
|
}
|
|
76
86
|
append(sessions, receipts) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
const state = this.open();
|
|
88
|
+
try {
|
|
89
|
+
for (const row of sessions) {
|
|
90
|
+
this.addSession(state, row);
|
|
91
|
+
}
|
|
92
|
+
if (receipts === undefined) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isUnknownArray(receipts)) {
|
|
96
|
+
throw new Error("Invalid topic delivery receipts");
|
|
97
|
+
}
|
|
98
|
+
state.receipts.push(...receipts.map(receipt));
|
|
82
99
|
}
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.fail();
|
|
102
|
+
throw error;
|
|
85
103
|
}
|
|
86
|
-
this.receipts.push(...receipts.map(receipt));
|
|
87
104
|
}
|
|
88
105
|
finish() {
|
|
106
|
+
const state = this.open();
|
|
89
107
|
const sessions = [];
|
|
90
|
-
for (const { info, topics, subscriptions } of
|
|
108
|
+
for (const { info, topics, subscriptions } of state.sessions.values()) {
|
|
91
109
|
sessions.push({
|
|
92
110
|
...info,
|
|
93
111
|
...(topics !== undefined ? { topics } : {}),
|
|
94
112
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
95
113
|
});
|
|
96
114
|
}
|
|
97
|
-
|
|
115
|
+
this.phase = { kind: "finished" };
|
|
116
|
+
return { sessions, receipts: state.receipts };
|
|
98
117
|
}
|
|
99
118
|
}
|
package/package.json
CHANGED
|
@@ -18,11 +18,15 @@ interface PresenceOwner {
|
|
|
18
18
|
readonly restored: (identity: Identity, expires: number) => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type PresencePhase =
|
|
22
|
+
| { readonly kind: "idle" }
|
|
23
|
+
| { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
|
|
24
|
+
| { readonly kind: "running"; readonly task: Promise<void> }
|
|
25
|
+
| { readonly kind: "stopped"; readonly task: Promise<void> };
|
|
26
|
+
|
|
21
27
|
export class PersistentPresence {
|
|
22
28
|
private readonly registrations = new Map<string, Registration>();
|
|
23
|
-
private
|
|
24
|
-
private timer?: NodeJS.Timeout;
|
|
25
|
-
private running?: Promise<void> | undefined;
|
|
29
|
+
private phase: PresencePhase = { kind: "idle" };
|
|
26
30
|
private readonly owner: PresenceOwner;
|
|
27
31
|
|
|
28
32
|
constructor(owner: PresenceOwner) {
|
|
@@ -88,22 +92,30 @@ export class PersistentPresence {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
start(): void {
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}, 0);
|
|
95
|
+
if (this.phase.kind === "running" || this.phase.kind === "stopped") {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.schedule(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private schedule(delay: number): void {
|
|
102
|
+
if (this.phase.kind === "scheduled") {
|
|
103
|
+
clearTimeout(this.phase.timer);
|
|
101
104
|
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
const task = this.maintain().finally(() => {
|
|
107
|
+
if (this.phase.kind !== "stopped") {
|
|
108
|
+
this.schedule(15000);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
this.phase = { kind: "running", task };
|
|
112
|
+
}, delay);
|
|
113
|
+
this.phase = { kind: "scheduled", timer };
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
private async maintain(): Promise<void> {
|
|
105
117
|
for (const registration of this.registrations.values()) {
|
|
106
|
-
if (this.stopped) {
|
|
118
|
+
if (this.phase.kind === "stopped") {
|
|
107
119
|
return;
|
|
108
120
|
}
|
|
109
121
|
const identity = this.owner.clients().get(registration.fingerprint);
|
|
@@ -125,9 +137,16 @@ export class PersistentPresence {
|
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
stop(): Promise<void> {
|
|
141
|
+
const phase = this.phase;
|
|
142
|
+
if (phase.kind === "stopped") {
|
|
143
|
+
return phase.task;
|
|
144
|
+
}
|
|
145
|
+
if (phase.kind === "scheduled") {
|
|
146
|
+
clearTimeout(phase.timer);
|
|
147
|
+
}
|
|
148
|
+
const task = phase.kind === "running" ? phase.task : Promise.resolve();
|
|
149
|
+
this.phase = { kind: "stopped", task };
|
|
150
|
+
return task;
|
|
132
151
|
}
|
|
133
152
|
}
|
|
@@ -15,6 +15,12 @@ interface Candidate {
|
|
|
15
15
|
readonly entry: PendingNotification;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type DeliveryPhase =
|
|
19
|
+
| { readonly kind: "idle" }
|
|
20
|
+
| { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
|
|
21
|
+
| { readonly kind: "running" | "running-dirty"; readonly task: Promise<void> }
|
|
22
|
+
| { readonly kind: "stopped"; readonly task: Promise<void> };
|
|
23
|
+
|
|
18
24
|
function actionable(entry: PendingNotification): boolean {
|
|
19
25
|
const message = entry.message;
|
|
20
26
|
if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
|
|
@@ -39,9 +45,7 @@ export class WakeDeliveries {
|
|
|
39
45
|
private readonly allowed: (identity: Identity) => boolean;
|
|
40
46
|
private readonly deliveries = new Map<string, Delivery>();
|
|
41
47
|
private readonly controller = new AbortController();
|
|
42
|
-
private
|
|
43
|
-
private running?: Promise<void> | undefined;
|
|
44
|
-
private dirty = false;
|
|
48
|
+
private phase: DeliveryPhase = { kind: "idle" };
|
|
45
49
|
private storageBackoff = 15000;
|
|
46
50
|
|
|
47
51
|
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
@@ -83,10 +87,17 @@ export class WakeDeliveries {
|
|
|
83
87
|
} catch {
|
|
84
88
|
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
85
89
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
const phase = this.phase;
|
|
91
|
+
switch (phase.kind) {
|
|
92
|
+
case "running":
|
|
93
|
+
this.phase = { kind: "running-dirty", task: phase.task };
|
|
94
|
+
return;
|
|
95
|
+
case "running-dirty":
|
|
96
|
+
case "stopped":
|
|
97
|
+
return;
|
|
98
|
+
case "idle":
|
|
99
|
+
case "scheduled":
|
|
100
|
+
this.schedule(Date.now());
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
@@ -101,31 +112,40 @@ export class WakeDeliveries {
|
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
private schedule(deadline: number): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.running = this.deliver()
|
|
110
|
-
.then((due) => {
|
|
111
|
-
next = due;
|
|
112
|
-
this.storageBackoff = 15000;
|
|
113
|
-
return;
|
|
114
|
-
})
|
|
115
|
-
.catch(() => {
|
|
116
|
-
log("wake_delivery", { result: "storage_unavailable" });
|
|
117
|
-
next = Date.now() + this.storageBackoff;
|
|
118
|
-
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
119
|
-
})
|
|
120
|
-
.finally(() => {
|
|
121
|
-
this.running = undefined;
|
|
122
|
-
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
123
|
-
this.schedule(this.dirty ? Date.now() : next);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
},
|
|
115
|
+
if (this.phase.kind === "scheduled") {
|
|
116
|
+
clearTimeout(this.phase.timer);
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(
|
|
119
|
+
() => this.startDelivery(),
|
|
127
120
|
Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
|
|
128
121
|
);
|
|
122
|
+
this.phase = { kind: "scheduled", timer };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private startDelivery(): void {
|
|
126
|
+
let next = Infinity;
|
|
127
|
+
const task = this.deliver()
|
|
128
|
+
.then((due) => {
|
|
129
|
+
next = due;
|
|
130
|
+
this.storageBackoff = 15000;
|
|
131
|
+
return;
|
|
132
|
+
})
|
|
133
|
+
.catch(() => {
|
|
134
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
135
|
+
next = Date.now() + this.storageBackoff;
|
|
136
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
137
|
+
})
|
|
138
|
+
.finally(() => {
|
|
139
|
+
if (this.phase.kind === "stopped") {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const dirty = this.phase.kind === "running-dirty";
|
|
143
|
+
this.phase = { kind: "idle" };
|
|
144
|
+
if (dirty || Number.isFinite(next)) {
|
|
145
|
+
this.schedule(dirty ? Date.now() : next);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this.phase = { kind: "running", task };
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
private authorized(delivery: Delivery): boolean {
|
|
@@ -267,14 +287,26 @@ export class WakeDeliveries {
|
|
|
267
287
|
this.changed(false);
|
|
268
288
|
}
|
|
269
289
|
|
|
270
|
-
|
|
271
|
-
this.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
for (const delivery of this.deliveries.values()) {
|
|
275
|
-
delivery.mailbox.close();
|
|
290
|
+
stop(): Promise<void> {
|
|
291
|
+
const phase = this.phase;
|
|
292
|
+
if (phase.kind === "stopped") {
|
|
293
|
+
return phase.task;
|
|
276
294
|
}
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
if (phase.kind === "scheduled") {
|
|
296
|
+
clearTimeout(phase.timer);
|
|
297
|
+
}
|
|
298
|
+
const running =
|
|
299
|
+
phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
|
|
300
|
+
const task = Promise.resolve(running).then(() => {
|
|
301
|
+
for (const delivery of this.deliveries.values()) {
|
|
302
|
+
delivery.mailbox.close();
|
|
303
|
+
}
|
|
304
|
+
this.deliveries.clear();
|
|
305
|
+
this.state?.close();
|
|
306
|
+
return;
|
|
307
|
+
});
|
|
308
|
+
this.phase = { kind: "stopped", task };
|
|
309
|
+
this.controller.abort();
|
|
310
|
+
return task;
|
|
279
311
|
}
|
|
280
312
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type SessionInfo,
|
|
19
19
|
} from "../types.ts";
|
|
20
20
|
import { writeMessage } from "./framing.ts";
|
|
21
|
+
import { PendingRequests } from "./pending-requests.ts";
|
|
21
22
|
|
|
22
23
|
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
23
24
|
export function lastRequest(msg: UnknownRecord): {
|
|
@@ -46,35 +47,25 @@ export class LastMessageRequests {
|
|
|
46
47
|
constructor(timeoutMs: number) {
|
|
47
48
|
this.timeoutMs = timeoutMs;
|
|
48
49
|
}
|
|
49
|
-
private readonly pending = new
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
readonly reject: (error: Readonly<Error>) => void;
|
|
54
|
-
readonly timer: NodeJS.Timeout;
|
|
55
|
-
}
|
|
56
|
-
>();
|
|
50
|
+
private readonly pending = new PendingRequests<{
|
|
51
|
+
readonly resolve: (result: NativeReadResult) => void;
|
|
52
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
53
|
+
}>();
|
|
57
54
|
|
|
58
55
|
fail(error: Readonly<Error>): void {
|
|
59
56
|
this.lastMessages = false;
|
|
60
57
|
this.receiptReads = false;
|
|
61
|
-
|
|
62
|
-
clearTimeout(pending.timer);
|
|
63
|
-
pending.reject(error);
|
|
64
|
-
}
|
|
65
|
-
this.pending.clear();
|
|
58
|
+
this.pending.fail(error);
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
receive(message: UnknownRecord): void {
|
|
69
62
|
if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
|
|
70
63
|
throw new Error("Invalid last-message response");
|
|
71
64
|
}
|
|
72
|
-
const pending = this.pending.
|
|
65
|
+
const pending = this.pending.take(message.requestId);
|
|
73
66
|
if (!pending) {
|
|
74
67
|
return;
|
|
75
68
|
}
|
|
76
|
-
clearTimeout(pending.timer);
|
|
77
|
-
this.pending.delete(message.requestId);
|
|
78
69
|
pending.resolve(message.result);
|
|
79
70
|
}
|
|
80
71
|
|
|
@@ -100,18 +91,16 @@ export class LastMessageRequests {
|
|
|
100
91
|
}
|
|
101
92
|
return new Promise((resolve, reject) => {
|
|
102
93
|
const requestId = randomUUID();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
this.pending.set(
|
|
95
|
+
requestId,
|
|
96
|
+
{ resolve, reject },
|
|
97
|
+
this.timeoutMs,
|
|
98
|
+
() => new Error("Last-message read timeout"),
|
|
99
|
+
);
|
|
109
100
|
try {
|
|
110
101
|
writeMessage(socket, { type: "last_request", requestId, to, query });
|
|
111
102
|
} catch (error) {
|
|
112
|
-
|
|
113
|
-
this.pending.delete(requestId);
|
|
114
|
-
reject(new Error(errorMessage(error)));
|
|
103
|
+
this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
|
|
115
104
|
}
|
|
116
105
|
});
|
|
117
106
|
}
|
|
@@ -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";
|
|
@@ -70,18 +71,15 @@ export class IntercomClient extends EventEmitter {
|
|
|
70
71
|
readonly wakes = new WakeMessages(this, () => this.requireActiveSocket());
|
|
71
72
|
private guardedDeliverySupported = false;
|
|
72
73
|
private readonly lastRequests: LastMessageRequests;
|
|
73
|
-
private readonly pendingSends = new
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
>();
|
|
77
|
-
private readonly pendingLists = new
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
readonly reader: SnapshotReader;
|
|
83
|
-
}
|
|
84
|
-
>();
|
|
74
|
+
private readonly pendingSends = new PendingRequests<{
|
|
75
|
+
readonly resolve: (r: SendResult) => void;
|
|
76
|
+
readonly reject: (e: Readonly<Error>) => void;
|
|
77
|
+
}>();
|
|
78
|
+
private readonly pendingLists = new PendingRequests<{
|
|
79
|
+
readonly resolve: (snapshot: SessionSnapshot) => void;
|
|
80
|
+
readonly reject: (e: Readonly<Error>) => void;
|
|
81
|
+
readonly reader: SnapshotReader;
|
|
82
|
+
}>();
|
|
85
83
|
private connecting = false;
|
|
86
84
|
private disconnecting = false;
|
|
87
85
|
private readonly sendTimeoutMs: number;
|
|
@@ -96,14 +94,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
96
94
|
|
|
97
95
|
private failPending(error: Readonly<Error>): void {
|
|
98
96
|
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();
|
|
97
|
+
this.pendingSends.fail(error);
|
|
98
|
+
this.pendingLists.fail(error);
|
|
107
99
|
}
|
|
108
100
|
|
|
109
101
|
get sessionId(): string | null {
|
|
@@ -301,12 +293,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
301
293
|
if (type !== "delivered" && typeof reason !== "string") {
|
|
302
294
|
throw new Error(`Invalid ${errorMessage(type)} message`);
|
|
303
295
|
}
|
|
304
|
-
const pending = this.pendingSends.
|
|
296
|
+
const pending = this.pendingSends.take(messageId);
|
|
305
297
|
// Late responses are harmless after the caller has timed out.
|
|
306
298
|
if (!pending) {
|
|
307
299
|
return;
|
|
308
300
|
}
|
|
309
|
-
this.pendingSends.delete(messageId);
|
|
310
301
|
pending.resolve({
|
|
311
302
|
id: messageId,
|
|
312
303
|
accepted: type !== "delivery_failed",
|
|
@@ -330,14 +321,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
330
321
|
return;
|
|
331
322
|
}
|
|
332
323
|
if (typeof error === "string") {
|
|
333
|
-
this.pendingLists.
|
|
334
|
-
pending.reject(new Error(error));
|
|
324
|
+
this.pendingLists.take(requestId)?.reject(new Error(error));
|
|
335
325
|
return;
|
|
336
326
|
}
|
|
337
327
|
pending.reader.append(sessions, receipts);
|
|
338
328
|
if (more !== true) {
|
|
339
|
-
this.pendingLists.
|
|
340
|
-
pending.resolve(pending.reader.finish());
|
|
329
|
+
this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
|
|
341
330
|
}
|
|
342
331
|
}
|
|
343
332
|
|
|
@@ -396,8 +385,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
396
385
|
|
|
397
386
|
return new Promise((resolve, reject) => {
|
|
398
387
|
const requestId = randomUUID();
|
|
388
|
+
const reader = new SnapshotReader();
|
|
399
389
|
const wrappedResolve = (snapshot: SessionSnapshot) => {
|
|
400
|
-
clearTimeout(timeout);
|
|
401
390
|
try {
|
|
402
391
|
// Commit confirmed subscriptions before handling a following message in the same read.
|
|
403
392
|
onAccepted?.(snapshot);
|
|
@@ -407,23 +396,19 @@ export class IntercomClient extends EventEmitter {
|
|
|
407
396
|
}
|
|
408
397
|
};
|
|
409
398
|
const wrappedReject = (error: Readonly<Error>) => {
|
|
410
|
-
|
|
399
|
+
reader.fail();
|
|
411
400
|
reject(error);
|
|
412
401
|
};
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
resolve: wrappedResolve,
|
|
424
|
-
reject: wrappedReject,
|
|
425
|
-
reader: new SnapshotReader(),
|
|
426
|
-
});
|
|
402
|
+
this.pendingLists.set(
|
|
403
|
+
requestId,
|
|
404
|
+
{
|
|
405
|
+
resolve: wrappedResolve,
|
|
406
|
+
reject: wrappedReject,
|
|
407
|
+
reader,
|
|
408
|
+
},
|
|
409
|
+
this.listTimeoutMs,
|
|
410
|
+
() => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
|
|
411
|
+
);
|
|
427
412
|
try {
|
|
428
413
|
writeMessage(socket, {
|
|
429
414
|
type: "list",
|
|
@@ -432,9 +417,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
432
417
|
...(change ? { change } : {}),
|
|
433
418
|
});
|
|
434
419
|
} catch (error) {
|
|
435
|
-
|
|
436
|
-
this.pendingLists.delete(requestId);
|
|
437
|
-
reject(toError(error));
|
|
420
|
+
this.pendingLists.take(requestId)?.reject(toError(error));
|
|
438
421
|
}
|
|
439
422
|
});
|
|
440
423
|
}
|
|
@@ -456,7 +439,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
456
439
|
}
|
|
457
440
|
|
|
458
441
|
const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
|
|
459
|
-
if (this.pendingSends.
|
|
442
|
+
if (this.pendingSends.get(messageId)) {
|
|
460
443
|
return Promise.reject(new Error("Message ID already has a pending send"));
|
|
461
444
|
}
|
|
462
445
|
const message: Message = {
|
|
@@ -482,22 +465,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
482
465
|
return Promise.reject(new Error("Invalid message format"));
|
|
483
466
|
}
|
|
484
467
|
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 });
|
|
468
|
+
this.pendingSends.set(
|
|
469
|
+
messageId,
|
|
470
|
+
{ resolve, reject },
|
|
471
|
+
this.sendTimeoutMs,
|
|
472
|
+
() => new Error("Send timeout"),
|
|
473
|
+
);
|
|
501
474
|
|
|
502
475
|
try {
|
|
503
476
|
writeMessage(socket, {
|
|
@@ -506,9 +479,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
506
479
|
message: this._topicsSupported ? compactTopicMessage(message) : message,
|
|
507
480
|
});
|
|
508
481
|
} catch (error) {
|
|
509
|
-
|
|
510
|
-
this.pendingSends.delete(messageId);
|
|
511
|
-
reject(toError(error));
|
|
482
|
+
this.pendingSends.take(messageId)?.reject(toError(error));
|
|
512
483
|
}
|
|
513
484
|
});
|
|
514
485
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface Request {
|
|
2
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
6
|
+
export class PendingRequests<T extends Request> {
|
|
7
|
+
private readonly pending = new Map<
|
|
8
|
+
string,
|
|
9
|
+
{ readonly value: T; readonly timer: NodeJS.Timeout }
|
|
10
|
+
>();
|
|
11
|
+
|
|
12
|
+
get(id: string): T | undefined {
|
|
13
|
+
return this.pending.get(id)?.value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void {
|
|
17
|
+
const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
|
|
18
|
+
timer.unref();
|
|
19
|
+
this.pending.set(id, { value, timer });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
take(id: string): T | undefined {
|
|
23
|
+
const pending = this.pending.get(id);
|
|
24
|
+
if (pending === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.pending.delete(id);
|
|
28
|
+
clearTimeout(pending.timer);
|
|
29
|
+
return pending.value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fail(error: Readonly<Error>): void {
|
|
33
|
+
for (const id of this.pending.keys()) {
|
|
34
|
+
this.take(id)?.reject(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -58,6 +58,14 @@ interface SessionParts {
|
|
|
58
58
|
readonly subscriptions: TopicSubscription[] | undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
type SnapshotPhase =
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: "open";
|
|
64
|
+
readonly sessions: Map<string, SessionParts>;
|
|
65
|
+
readonly receipts: Receipt[];
|
|
66
|
+
}
|
|
67
|
+
| { readonly kind: "finished" | "failed" };
|
|
68
|
+
|
|
61
69
|
function appendRows<T>(
|
|
62
70
|
previous: T[] | undefined,
|
|
63
71
|
rows: readonly T[] | undefined,
|
|
@@ -75,16 +83,28 @@ function appendRows<T>(
|
|
|
75
83
|
|
|
76
84
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
77
85
|
export class SnapshotReader {
|
|
78
|
-
private
|
|
79
|
-
|
|
86
|
+
private phase: SnapshotPhase = { kind: "open", sessions: new Map(), receipts: [] };
|
|
87
|
+
|
|
88
|
+
private open(): Extract<SnapshotPhase, { readonly kind: "open" }> {
|
|
89
|
+
if (this.phase.kind !== "open") {
|
|
90
|
+
throw new Error(`Session snapshot is ${this.phase.kind}`);
|
|
91
|
+
}
|
|
92
|
+
return this.phase;
|
|
93
|
+
}
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
fail(): void {
|
|
96
|
+
if (this.phase.kind === "open") {
|
|
97
|
+
this.phase = { kind: "failed" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private addSession(state: Extract<SnapshotPhase, { readonly kind: "open" }>, row: unknown): void {
|
|
82
102
|
if (!isRecord(row) || typeof row.id !== "string") {
|
|
83
103
|
throw new Error("Invalid session snapshot row");
|
|
84
104
|
}
|
|
85
105
|
const topics = optionalArray(row.topics);
|
|
86
106
|
const subscriptions = optionalArray(row.subscriptions);
|
|
87
|
-
const previous =
|
|
107
|
+
const previous = state.sessions.get(row.id);
|
|
88
108
|
const info = normalizeSessionInfo({
|
|
89
109
|
...previous?.info,
|
|
90
110
|
...row,
|
|
@@ -95,7 +115,7 @@ export class SnapshotReader {
|
|
|
95
115
|
throw new Error("Invalid session snapshot");
|
|
96
116
|
}
|
|
97
117
|
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
98
|
-
|
|
118
|
+
state.sessions.set(info.id, {
|
|
99
119
|
info: identity,
|
|
100
120
|
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
101
121
|
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
@@ -103,27 +123,35 @@ export class SnapshotReader {
|
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
append(sessions: readonly unknown[], receipts: unknown): void {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
const state = this.open();
|
|
127
|
+
try {
|
|
128
|
+
for (const row of sessions) {
|
|
129
|
+
this.addSession(state, row);
|
|
130
|
+
}
|
|
131
|
+
if (receipts === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!isUnknownArray(receipts)) {
|
|
135
|
+
throw new Error("Invalid topic delivery receipts");
|
|
136
|
+
}
|
|
137
|
+
state.receipts.push(...receipts.map(receipt));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.fail();
|
|
140
|
+
throw error;
|
|
114
141
|
}
|
|
115
|
-
this.receipts.push(...receipts.map(receipt));
|
|
116
142
|
}
|
|
117
143
|
|
|
118
144
|
finish(): SessionSnapshot {
|
|
145
|
+
const state = this.open();
|
|
119
146
|
const sessions: SessionInfo[] = [];
|
|
120
|
-
for (const { info, topics, subscriptions } of
|
|
147
|
+
for (const { info, topics, subscriptions } of state.sessions.values()) {
|
|
121
148
|
sessions.push({
|
|
122
149
|
...info,
|
|
123
150
|
...(topics !== undefined ? { topics } : {}),
|
|
124
151
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
125
152
|
});
|
|
126
153
|
}
|
|
127
|
-
|
|
154
|
+
this.phase = { kind: "finished" };
|
|
155
|
+
return { sessions, receipts: state.receipts };
|
|
128
156
|
}
|
|
129
157
|
}
|