poltertab 1.8.0-beta.1 → 1.8.0-beta.3
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/README.md +10 -0
- package/mcp-server/bridge.js +517 -37
- package/mcp-server/config.js +68 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -250,6 +250,16 @@ limit). A secondary refused for capacity is told why and retries with backoff
|
|
|
250
250
|
secondary promotes itself to replace it and the others reconnect to it. The
|
|
251
251
|
extension popup lists whichever agents are connected.
|
|
252
252
|
|
|
253
|
+
A secondary that sends no command for 5 minutes gives up its slot and
|
|
254
|
+
reconnects on its next tool call (`POLTERTAB_IDLE_EVICTION_MS`). The primary
|
|
255
|
+
sends at most 6 commands to the extension at once
|
|
256
|
+
(`POLTERTAB_MAX_CONCURRENCY`) and queues up to 3 more per secondary
|
|
257
|
+
(`POLTERTAB_QUEUE_DEPTH`), taking turns between secondaries. A command beyond
|
|
258
|
+
that fails at once with `[queue_full]`; one that waits longer than 60s fails
|
|
259
|
+
with `[queue_timeout]` and never runs (`POLTERTAB_QUEUE_TIMEOUT_MS`).
|
|
260
|
+
`curl http://127.0.0.1:7822/status` shows the primary's secondaries and queue.
|
|
261
|
+
It answers on this machine only.
|
|
262
|
+
|
|
253
263
|
**Changing the WebSocket Port:**
|
|
254
264
|
Only needed when something that *isn't* PolterTab already holds `7822` —
|
|
255
265
|
PolterTab servers share that port by design (see above). Both sides must move:
|
package/mcp-server/bridge.js
CHANGED
|
@@ -28,6 +28,10 @@ const {
|
|
|
28
28
|
PROMOTION_BACKOFF_MAX_MS,
|
|
29
29
|
PROMOTION_BACKOFF_JITTER,
|
|
30
30
|
MAX_SECONDARIES,
|
|
31
|
+
IDLE_EVICTION_MS,
|
|
32
|
+
MAX_EXTENSION_CONCURRENCY,
|
|
33
|
+
PER_SECONDARY_QUEUE_DEPTH,
|
|
34
|
+
QUEUE_TIMEOUT_MS,
|
|
31
35
|
POLTERTAB_HOME,
|
|
32
36
|
} = require("./config.js");
|
|
33
37
|
const updates = require("./update-check.js");
|
|
@@ -50,6 +54,7 @@ let isSecondary = false;
|
|
|
50
54
|
let nodeId = null;
|
|
51
55
|
let wss = null;
|
|
52
56
|
let httpServer = null;
|
|
57
|
+
// (httpServer6 removed — wildcard bind covers ::1; see fix/legacy-bind)
|
|
53
58
|
|
|
54
59
|
// Secondary-side retry state. One counter for the whole reconnect/probe/promote
|
|
55
60
|
// cycle, reset only by a Primary's secondary_accepted; `refusal` and
|
|
@@ -60,6 +65,104 @@ let retryTimer = null;
|
|
|
60
65
|
let refusal = null;
|
|
61
66
|
let unreachable = false;
|
|
62
67
|
|
|
68
|
+
// Idle eviction, Secondary side. A 4001 from the Primary parks this node: no
|
|
69
|
+
// reconnect, no probe, no promotion. The next sendCommand dials again, and
|
|
70
|
+
// `reconnect` is that dial while it is in flight, shared by every caller that
|
|
71
|
+
// arrives meanwhile so they cost one connection, not one each (R-S3).
|
|
72
|
+
let isEvicted = false;
|
|
73
|
+
let reconnect = null; // { promise, resolve, reject, timer }
|
|
74
|
+
|
|
75
|
+
// Command queue, Primary side (PR-3). At most MAX_EXTENSION_CONCURRENCY
|
|
76
|
+
// commands may be in flight to the extension at once. v2 Secondary commands
|
|
77
|
+
// beyond that wait in a per-secondary FIFO, served round-robin across
|
|
78
|
+
// secondaries and the primary itself. Legacy commands bypass the queue.
|
|
79
|
+
//
|
|
80
|
+
// queuedBySource maps sourceId → [{id, action, ackSentAt, callerWs, onDispatch,
|
|
81
|
+
// onTimeout}]. sourceId is the secondary ws for proxy commands, or the symbol
|
|
82
|
+
// PRIMARY_SOURCE for the primary's own commands. roundRobinOrder is the list of
|
|
83
|
+
// active sources in round-robin order; rrCursor is the next position to serve.
|
|
84
|
+
// waitHistory holds completed wait-times (ms) for /status percentiles.
|
|
85
|
+
let extensionInFlight = 0;
|
|
86
|
+
const PRIMARY_SOURCE = Symbol("primary");
|
|
87
|
+
const queuedBySource = new Map();
|
|
88
|
+
const roundRobinOrder = [];
|
|
89
|
+
let rrCursor = 0;
|
|
90
|
+
const waitHistory = [];
|
|
91
|
+
|
|
92
|
+
// Enqueue an item for a source. If the source has no queue yet, append it to
|
|
93
|
+
// the round-robin order so it gets served on the next dequeue.
|
|
94
|
+
function _rrEnqueue(sourceId, item) {
|
|
95
|
+
if (!queuedBySource.has(sourceId)) {
|
|
96
|
+
queuedBySource.set(sourceId, []);
|
|
97
|
+
roundRobinOrder.push(sourceId);
|
|
98
|
+
}
|
|
99
|
+
queuedBySource.get(sourceId).push(item);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pop the next item according to the round-robin order. After taking from a
|
|
103
|
+
// source, advance the cursor: if the source is now empty, remove it and leave
|
|
104
|
+
// the cursor where it is (the next source slid into that slot); otherwise move
|
|
105
|
+
// the cursor to the next source.
|
|
106
|
+
function _rrDequeue() {
|
|
107
|
+
if (roundRobinOrder.length === 0) return null;
|
|
108
|
+
if (rrCursor >= roundRobinOrder.length) rrCursor = 0;
|
|
109
|
+
const sourceId = roundRobinOrder[rrCursor];
|
|
110
|
+
const items = queuedBySource.get(sourceId);
|
|
111
|
+
const item = items.shift();
|
|
112
|
+
if (items.length === 0) {
|
|
113
|
+
queuedBySource.delete(sourceId);
|
|
114
|
+
roundRobinOrder.splice(rrCursor, 1);
|
|
115
|
+
if (rrCursor >= roundRobinOrder.length) rrCursor = 0;
|
|
116
|
+
} else {
|
|
117
|
+
rrCursor = (rrCursor + 1) % roundRobinOrder.length;
|
|
118
|
+
}
|
|
119
|
+
return item;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Try to dispatch as many queued items as the in-flight cap allows. Skips items
|
|
123
|
+
// whose caller disconnected or whose deadline has passed (both are dropped
|
|
124
|
+
// without consuming a slot).
|
|
125
|
+
function _tryDequeue() {
|
|
126
|
+
while (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
|
|
127
|
+
if (roundRobinOrder.length === 0) break;
|
|
128
|
+
const item = _rrDequeue();
|
|
129
|
+
if (!item) break;
|
|
130
|
+
|
|
131
|
+
// Deadline check: ackSentAt + budget − 2 s. The −2 s keeps the secondary's
|
|
132
|
+
// own timeout from firing *after* the drop (the secondary re-arms to budget
|
|
133
|
+
// on the queued frame, so the margin is budget − (budget − 2 s) = 2 s).
|
|
134
|
+
if (Date.now() > item.ackSentAt + COMMAND_TIMEOUT_MS - 2000) {
|
|
135
|
+
item.onTimeout();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Caller gone: drop silently.
|
|
139
|
+
if (item.callerWs && item.callerWs.readyState !== WebSocket.OPEN) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Extension disconnected since the item was enqueued: reject to caller
|
|
143
|
+
// without consuming a slot. This is the guard against the MUST-1 crash:
|
|
144
|
+
// _dispatchProxyNow calls extensionSocket.send() synchronously, so a null
|
|
145
|
+
// socket inside a timer callback becomes an uncaughtException that kills
|
|
146
|
+
// the Primary and every Secondary with it.
|
|
147
|
+
if (!extensionSocket || extensionSocket.readyState !== WebSocket.OPEN) {
|
|
148
|
+
if (item.callerWs && item.callerWs.readyState === WebSocket.OPEN) {
|
|
149
|
+
item.callerWs.send(
|
|
150
|
+
JSON.stringify({ id: item.id, success: false, error: "Browser extension not connected to Primary." }),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
extensionInFlight++;
|
|
157
|
+
const waitMs = Date.now() - item.ackSentAt;
|
|
158
|
+
waitHistory.push(waitMs);
|
|
159
|
+
if (waitHistory.length > 1000) waitHistory.shift();
|
|
160
|
+
item.onDispatch();
|
|
161
|
+
// onDispatch may or may not keep extensionInFlight++ live (it fires the
|
|
162
|
+
// extension send); the loop re-checks the cap after each dispatch.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
63
166
|
function broadcastSessionState() {
|
|
64
167
|
if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
|
|
65
168
|
const sessions = [];
|
|
@@ -113,7 +216,53 @@ function allowOrigin(info, cb) {
|
|
|
113
216
|
|
|
114
217
|
function setupPrimaryServer() {
|
|
115
218
|
const http = require("http");
|
|
116
|
-
|
|
219
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
220
|
+
const handleRequest = (req, res) => {
|
|
221
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
222
|
+
if (!LOOPBACK.has(req.socket.remoteAddress)) {
|
|
223
|
+
res.writeHead(404);
|
|
224
|
+
res.end();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
const secondaries = [];
|
|
229
|
+
for (const [nid, ws] of secondaryClients.entries()) {
|
|
230
|
+
const srcQueue = queuedBySource.get(ws);
|
|
231
|
+
secondaries.push({
|
|
232
|
+
nodeId: nid,
|
|
233
|
+
version: ws.version || null,
|
|
234
|
+
protocol: ws.supportsNewCloseCodes ? "v2" : "legacy",
|
|
235
|
+
pid: ws.pid || null,
|
|
236
|
+
idle_ms: Math.floor(now - ws.lastCommandAt),
|
|
237
|
+
queued_commands: srcQueue ? srcQueue.length : 0,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
let totalQueued = 0;
|
|
241
|
+
for (const items of queuedBySource.values()) totalQueued += items.length;
|
|
242
|
+
const sorted = waitHistory.slice().sort((a, b) => a - b);
|
|
243
|
+
const pct = (p) => sorted.length ? sorted[Math.floor((sorted.length - 1) * p)] : 0;
|
|
244
|
+
const body = JSON.stringify({
|
|
245
|
+
role: "primary",
|
|
246
|
+
pid: process.pid,
|
|
247
|
+
port: WS_PORT,
|
|
248
|
+
version: OWN_VERSION,
|
|
249
|
+
secondaries,
|
|
250
|
+
queue: {
|
|
251
|
+
in_flight: extensionInFlight,
|
|
252
|
+
max_in_flight: MAX_EXTENSION_CONCURRENCY,
|
|
253
|
+
queued: totalQueued,
|
|
254
|
+
max_queued_per_secondary: PER_SECONDARY_QUEUE_DEPTH,
|
|
255
|
+
wait_percentiles_ms: { p50: pct(0.5), p95: pct(0.95), p99: pct(0.99) },
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
259
|
+
res.end(body);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
res.writeHead(404);
|
|
263
|
+
res.end();
|
|
264
|
+
};
|
|
265
|
+
httpServer = http.createServer(handleRequest);
|
|
117
266
|
|
|
118
267
|
httpServer.on("error", (e) => {
|
|
119
268
|
if (e.code === "EADDRINUSE") {
|
|
@@ -130,10 +279,26 @@ function setupPrimaryServer() {
|
|
|
130
279
|
}
|
|
131
280
|
});
|
|
132
281
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
282
|
+
// Why probe-first (SHOULD-1 narrative):
|
|
283
|
+
// We bind :: (IPv6 wildcard, dual-stack) so a second wildcard bind from a
|
|
284
|
+
// legacy 1.7.x/beta.1 server gets EADDRINUSE on every platform. But on
|
|
285
|
+
// macOS SO_REUSEADDR, a wildcard bind does NOT give EADDRINUSE when the
|
|
286
|
+
// existing listener is a specific-address bind (127.0.0.1/::1) — the old
|
|
287
|
+
// regression this PR fixes. The probe detects a real primary already
|
|
288
|
+
// serving 127.0.0.1 and demotes us before we call listen(), avoiding a
|
|
289
|
+
// race in the milliseconds between a successful probe and our own bind.
|
|
290
|
+
// The EADDRINUSE handler above covers the symmetric case.
|
|
291
|
+
probePrimary().then((alive) => {
|
|
292
|
+
if (alive) {
|
|
293
|
+
console.error(
|
|
294
|
+
`[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
|
|
295
|
+
);
|
|
296
|
+
httpServer.close();
|
|
297
|
+
httpServer = null;
|
|
298
|
+
startSecondaryMode();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
httpServer.listen(WS_PORT, () => {
|
|
137
302
|
wss = new WebSocket.WebSocketServer({
|
|
138
303
|
server: httpServer,
|
|
139
304
|
verifyClient: allowOrigin,
|
|
@@ -168,8 +333,65 @@ function setupPrimaryServer() {
|
|
|
168
333
|
});
|
|
169
334
|
}, HEARTBEAT_MS);
|
|
170
335
|
|
|
336
|
+
// Idle eviction (4001) for v2 Secondaries silent for IDLE_EVICTION_MS. An
|
|
337
|
+
// old Secondary is never evicted: it answers any close by trying to
|
|
338
|
+
// promote. One with a command in flight is skipped (R-S2) — its clock
|
|
339
|
+
// restarts when the reply goes back. The check runs at half the idle
|
|
340
|
+
// period, capped at the heartbeat, so an eviction lands at most that late.
|
|
341
|
+
const evictionInterval = setInterval(() => {
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
for (const ws of secondaryClients.values()) {
|
|
344
|
+
if (!ws.supportsNewCloseCodes || ws.readyState !== WebSocket.OPEN) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const idleMs = now - ws.lastCommandAt;
|
|
348
|
+
if (idleMs < IDLE_EVICTION_MS) continue;
|
|
349
|
+
// NICE-1: also skip eviction when commands are queued for this source
|
|
350
|
+
// (spec §3.3: "zero in-flight AND zero queued").
|
|
351
|
+
const busy =
|
|
352
|
+
[...pendingCommands.values()].some((c) => c.isProxy && c.sourceWs === ws) ||
|
|
353
|
+
queuedBySource.has(ws);
|
|
354
|
+
if (busy) continue;
|
|
355
|
+
console.error(
|
|
356
|
+
`[PolterTab MCP] Evicting idle secondary ${ws.nodeId} (idle ${idleMs} ms)`,
|
|
357
|
+
);
|
|
358
|
+
ws.close(
|
|
359
|
+
4001,
|
|
360
|
+
JSON.stringify({ reason: "idle_evicted", idle_ms: idleMs }),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}, Math.max(100, Math.min(HEARTBEAT_MS, Math.floor(IDLE_EVICTION_MS / 2))));
|
|
364
|
+
|
|
365
|
+
// GC: drop queued commands that have outlived QUEUE_TIMEOUT_MS. The
|
|
366
|
+
// deadline check at dequeue handles most cases; this is a safety net for
|
|
367
|
+
// commands in a source that never gets dequeued (e.g. its slot is always
|
|
368
|
+
// occupied by a slow extension command). It fires the same onTimeout
|
|
369
|
+
// handler, which sends [queue_timeout] to the caller and removes the item.
|
|
370
|
+
const queueGcInterval = setInterval(() => {
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
for (const [sourceId, items] of queuedBySource.entries()) {
|
|
373
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
374
|
+
if (now - items[i].ackSentAt > QUEUE_TIMEOUT_MS) {
|
|
375
|
+
const [expired] = items.splice(i, 1);
|
|
376
|
+
expired.onTimeout();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (items.length === 0) {
|
|
380
|
+
queuedBySource.delete(sourceId);
|
|
381
|
+
const idx = roundRobinOrder.indexOf(sourceId);
|
|
382
|
+
if (idx !== -1) {
|
|
383
|
+
roundRobinOrder.splice(idx, 1);
|
|
384
|
+
if (rrCursor > idx) rrCursor--;
|
|
385
|
+
if (rrCursor >= roundRobinOrder.length) rrCursor = 0;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}, Math.min(QUEUE_TIMEOUT_MS, 10000));
|
|
390
|
+
|
|
171
391
|
wss.on("close", () => {
|
|
172
392
|
clearInterval(interval);
|
|
393
|
+
clearInterval(evictionInterval);
|
|
394
|
+
clearInterval(queueGcInterval);
|
|
173
395
|
});
|
|
174
396
|
|
|
175
397
|
// Network State Garbage Collector (TTL: 5 minutes)
|
|
@@ -186,6 +408,15 @@ function setupPrimaryServer() {
|
|
|
186
408
|
}, NETWORK_GC_INTERVAL_MS);
|
|
187
409
|
|
|
188
410
|
setupPrimaryWss(wss);
|
|
411
|
+
|
|
412
|
+
// Wildcard bind (:: dual-stack) covers both 127.0.0.1 and ::1, so Node 18
|
|
413
|
+
// clients that resolve localhost → ::1 reach the primary without a separate
|
|
414
|
+
// httpServer6. A legacy server attempting listen(port) with no host gets
|
|
415
|
+
// EADDRINUSE because two wildcard binds on the same port always collide.
|
|
416
|
+
console.error(
|
|
417
|
+
`[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
|
|
418
|
+
);
|
|
419
|
+
});
|
|
189
420
|
});
|
|
190
421
|
}
|
|
191
422
|
|
|
@@ -219,12 +450,55 @@ function scheduleRetry(fn, delay) {
|
|
|
219
450
|
retryTimer = setTimeout(fn, delay);
|
|
220
451
|
}
|
|
221
452
|
|
|
453
|
+
// Dial the Primary again after an eviction. Settles on secondary_accepted, not
|
|
454
|
+
// on the socket opening: a command sent before the ACK can land on a Primary
|
|
455
|
+
// that is about to refuse this node. A failed dial rejects at once with the
|
|
456
|
+
// cause (the close handler calls finishReconnect) and leaves the node on PR-1's
|
|
457
|
+
// ordinary schedule — backoff, probe, promote — so the caller is not held for
|
|
458
|
+
// the full command timeout and the port is still taken over if it is free.
|
|
459
|
+
// isEvicted is cleared here rather than on the ACK: from now on the node is
|
|
460
|
+
// reconnecting, and a node that promotes itself must not dial its own port.
|
|
461
|
+
function lazyReconnect() {
|
|
462
|
+
if (reconnect) return reconnect.promise;
|
|
463
|
+
isEvicted = false;
|
|
464
|
+
let settle;
|
|
465
|
+
const promise = new Promise((resolve, reject) => {
|
|
466
|
+
settle = { resolve, reject };
|
|
467
|
+
});
|
|
468
|
+
reconnect = {
|
|
469
|
+
promise,
|
|
470
|
+
...settle,
|
|
471
|
+
timer: setTimeout(
|
|
472
|
+
() => finishReconnect(new Error(reconnectFailed())),
|
|
473
|
+
COMMAND_TIMEOUT_MS,
|
|
474
|
+
),
|
|
475
|
+
};
|
|
476
|
+
connectToPrimary();
|
|
477
|
+
return promise;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function finishReconnect(err) {
|
|
481
|
+
if (!reconnect) return;
|
|
482
|
+
const r = reconnect;
|
|
483
|
+
reconnect = null;
|
|
484
|
+
clearTimeout(r.timer);
|
|
485
|
+
if (err) r.reject(err);
|
|
486
|
+
else r.resolve();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function reconnectFailed() {
|
|
490
|
+
return `[reconnect_failed port=${WS_PORT}] Could not reconnect to primary.`;
|
|
491
|
+
}
|
|
492
|
+
|
|
222
493
|
// Is anything listening on the port? Binding to find out is what leaked a
|
|
223
494
|
// server per attempt; a refused TCP connect is the only answer that means the
|
|
224
495
|
// Primary is gone.
|
|
225
496
|
function probePrimary() {
|
|
226
497
|
return new Promise((resolve) => {
|
|
227
|
-
|
|
498
|
+
// Dial 127.0.0.1 explicitly; 'localhost' resolves to ::1 first on Node 18
|
|
499
|
+
// (no happy-eyeballs), which would give a false ECONNREFUSED when our own
|
|
500
|
+
// Primary is listening on 127.0.0.1.
|
|
501
|
+
const sock = net.connect(WS_PORT, "127.0.0.1");
|
|
228
502
|
// A listener with a full accept backlog answers with neither connect nor
|
|
229
503
|
// error, which would park this node with no retry scheduled. Treat silence
|
|
230
504
|
// as "something is there": connectToPrimary's handshake timeout then sorts
|
|
@@ -251,7 +525,9 @@ async function promoteOrRejoin() {
|
|
|
251
525
|
}
|
|
252
526
|
|
|
253
527
|
function connectToPrimary() {
|
|
254
|
-
|
|
528
|
+
// Dial 127.0.0.1 explicitly so Node 18 (which resolves 'localhost' to ::1
|
|
529
|
+
// first with no fallback) does not get ECONNREFUSED against the IPv4 Primary.
|
|
530
|
+
const ws = new WebSocket(`ws://127.0.0.1:${WS_PORT}`, {
|
|
255
531
|
perMessageDeflate: true,
|
|
256
532
|
// A Primary that accepts TCP but never answers the upgrade (event loop
|
|
257
533
|
// blocked, SIGSTOP) would otherwise hold this node forever.
|
|
@@ -287,6 +563,18 @@ function connectToPrimary() {
|
|
|
287
563
|
backoffAttempt = 0;
|
|
288
564
|
refusal = null;
|
|
289
565
|
unreachable = false;
|
|
566
|
+
finishReconnect();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// `queued` and `dispatched` frames re-arm the awaitReply timer (U11)
|
|
570
|
+
// but never settle the command — the reply does that.
|
|
571
|
+
if (
|
|
572
|
+
(msg.type === "queued" || msg.type === "dispatched") &&
|
|
573
|
+
msg.id &&
|
|
574
|
+
pendingCommands.has(msg.id)
|
|
575
|
+
) {
|
|
576
|
+
const entry = pendingCommands.get(msg.id);
|
|
577
|
+
if (entry && entry.resetTimer) entry.resetTimer();
|
|
290
578
|
return;
|
|
291
579
|
}
|
|
292
580
|
if (msg.id && pendingCommands.has(msg.id)) {
|
|
@@ -323,6 +611,36 @@ function connectToPrimary() {
|
|
|
323
611
|
} catch (_) {}
|
|
324
612
|
}
|
|
325
613
|
|
|
614
|
+
// A lazy reconnect whose dial ended before the ACK fails now, with the
|
|
615
|
+
// reason when the Primary gave one. The branches below still run, so the
|
|
616
|
+
// node retries or promotes exactly as it would have without the caller.
|
|
617
|
+
if (!accepted) {
|
|
618
|
+
finishReconnect(
|
|
619
|
+
new Error(
|
|
620
|
+
info.reason === "capacity_rejected"
|
|
621
|
+
? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) has ${info.limit} secondaries. Retry later.`
|
|
622
|
+
: reconnectFailed(),
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// 4001 = idle_evicted. Park: no reconnect, no probe, no promotion — the
|
|
628
|
+
// next sendCommand dials. A command already on the wire when the Primary
|
|
629
|
+
// closed is lost, and retrying it could run it twice, so say so (R-S2).
|
|
630
|
+
if (code === 4001) {
|
|
631
|
+
isEvicted = true;
|
|
632
|
+
for (const [, { reject, timer, action }] of pendingCommands) {
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
reject(
|
|
635
|
+
new Error(
|
|
636
|
+
`[evicted_mid_command action=${action || "unknown"}] Session evicted while command was in progress. Not auto-retried.`,
|
|
637
|
+
),
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
pendingCommands.clear();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
326
644
|
// Reject all pending commands
|
|
327
645
|
const aborted =
|
|
328
646
|
info.reason === "capacity_rejected"
|
|
@@ -401,6 +719,43 @@ async function readNetworkState(params = {}) {
|
|
|
401
719
|
return { tabId, capturedRequests: data.length, data };
|
|
402
720
|
}
|
|
403
721
|
|
|
722
|
+
// Dispatch a proxy command to the extension. Must be called AFTER
|
|
723
|
+
// extensionInFlight has already been incremented by the caller. Arms the
|
|
724
|
+
// command timeout; on either a reply or a timeout, decrements extensionInFlight
|
|
725
|
+
// and drains the queue so the next waiting command gets its turn.
|
|
726
|
+
function _dispatchProxyNow(callerWs, id, action, params) {
|
|
727
|
+
const timer = setTimeout(() => {
|
|
728
|
+
pendingCommands.delete(id);
|
|
729
|
+
extensionInFlight--;
|
|
730
|
+
_tryDequeue();
|
|
731
|
+
if (callerWs.readyState === WebSocket.OPEN) {
|
|
732
|
+
callerWs.send(
|
|
733
|
+
JSON.stringify({ id, success: false, error: `Command '${action}' timed out.` }),
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}, COMMAND_TIMEOUT_MS);
|
|
737
|
+
pendingCommands.set(id, { isProxy: true, sourceWs: callerWs, timer });
|
|
738
|
+
// Belt-and-braces: _tryDequeue's open-check guards the common case; this
|
|
739
|
+
// catches the narrow race where the extension socket closes between that
|
|
740
|
+
// check and the actual send (both are synchronous, but the close event is
|
|
741
|
+
// delivered between event-loop ticks, so the race is real on high-load paths).
|
|
742
|
+
try {
|
|
743
|
+
extensionSocket.send(
|
|
744
|
+
JSON.stringify({ ..._buildEnvelope(action, params, callerWs.nodeId), id }),
|
|
745
|
+
);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
clearTimeout(timer);
|
|
748
|
+
pendingCommands.delete(id);
|
|
749
|
+
extensionInFlight--;
|
|
750
|
+
_tryDequeue();
|
|
751
|
+
if (callerWs.readyState === WebSocket.OPEN) {
|
|
752
|
+
callerWs.send(
|
|
753
|
+
JSON.stringify({ id, success: false, error: "Browser extension not connected to Primary." }),
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
404
759
|
function setupPrimaryWss(wss) {
|
|
405
760
|
wss.on("connection", (ws) => {
|
|
406
761
|
ws.isAlive = true;
|
|
@@ -451,6 +806,7 @@ function setupPrimaryWss(wss) {
|
|
|
451
806
|
ws.version = msg.version || null;
|
|
452
807
|
ws.pid = msg.pid || null;
|
|
453
808
|
ws.parentPid = msg.parentPid || null;
|
|
809
|
+
ws.lastCommandAt = Date.now();
|
|
454
810
|
console.error(
|
|
455
811
|
`[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
|
|
456
812
|
);
|
|
@@ -491,6 +847,8 @@ function setupPrimaryWss(wss) {
|
|
|
491
847
|
}
|
|
492
848
|
return;
|
|
493
849
|
}
|
|
850
|
+
// Each command restarts this Secondary's idle clock.
|
|
851
|
+
ws.lastCommandAt = Date.now();
|
|
494
852
|
// Stamp which agent is asking, from the socket rather than the
|
|
495
853
|
// message, so the extension keeps this Secondary's current tab apart
|
|
496
854
|
// from the Primary's and every other Secondary's.
|
|
@@ -534,29 +892,73 @@ function setupPrimaryWss(wss) {
|
|
|
534
892
|
return;
|
|
535
893
|
}
|
|
536
894
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
895
|
+
// Legacy secondaries bypass the queue: they cannot receive `queued`
|
|
896
|
+
// or `dispatched` frames, so they go straight to the extension and
|
|
897
|
+
// keep today's timing contract. They still count toward extensionInFlight
|
|
898
|
+
// so v2 commands behind them wait their turn.
|
|
899
|
+
if (!ws.supportsNewCloseCodes) {
|
|
900
|
+
extensionInFlight++;
|
|
901
|
+
const timer = setTimeout(() => {
|
|
902
|
+
pendingCommands.delete(id);
|
|
903
|
+
extensionInFlight--;
|
|
904
|
+
_tryDequeue();
|
|
905
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
906
|
+
ws.send(JSON.stringify({ id, success: false, error: `Command '${action}' timed out.` }));
|
|
907
|
+
}
|
|
908
|
+
}, COMMAND_TIMEOUT_MS);
|
|
909
|
+
pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
|
|
910
|
+
extensionSocket.send(JSON.stringify({ ..._buildEnvelope(action, msg.params, ws.nodeId), id }));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// v2 Secondary: use the concurrency cap and queue.
|
|
915
|
+
if (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
|
|
916
|
+
// Free slot: dispatch immediately. No queued frame.
|
|
917
|
+
extensionInFlight++;
|
|
918
|
+
_dispatchProxyNow(ws, id, action, msg.params);
|
|
919
|
+
} else {
|
|
920
|
+
// Slot full: enqueue if there is room in this secondary's queue.
|
|
921
|
+
const srcQueue = queuedBySource.get(ws);
|
|
922
|
+
const depth = srcQueue ? srcQueue.length : 0;
|
|
923
|
+
if (depth >= PER_SECONDARY_QUEUE_DEPTH) {
|
|
924
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
925
|
+
ws.send(JSON.stringify({
|
|
542
926
|
id,
|
|
543
927
|
success: false,
|
|
544
|
-
error: `
|
|
545
|
-
})
|
|
546
|
-
|
|
928
|
+
error: `[queue_full action=${action}] Per-secondary queue depth (${PER_SECONDARY_QUEUE_DEPTH}) reached. Try again shortly.`,
|
|
929
|
+
}));
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const ackSentAt = Date.now();
|
|
934
|
+
const position = depth + 1;
|
|
935
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
936
|
+
ws.send(JSON.stringify({ type: "queued", id, position }));
|
|
547
937
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
|
|
551
|
-
// Same envelope construction as the direct path, so a tool argument
|
|
552
|
-
// named id or action cannot override the routing here either. The
|
|
553
|
-
// Primary's own id is kept last: pendingCommands is keyed on it.
|
|
554
|
-
extensionSocket.send(
|
|
555
|
-
JSON.stringify({
|
|
556
|
-
..._buildEnvelope(action, msg.params, ws.nodeId),
|
|
938
|
+
_rrEnqueue(ws, {
|
|
557
939
|
id,
|
|
558
|
-
|
|
559
|
-
|
|
940
|
+
action,
|
|
941
|
+
params: msg.params,
|
|
942
|
+
nodeId: ws.nodeId,
|
|
943
|
+
ackSentAt,
|
|
944
|
+
callerWs: ws,
|
|
945
|
+
onDispatch() {
|
|
946
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
947
|
+
ws.send(JSON.stringify({ type: "dispatched", id }));
|
|
948
|
+
}
|
|
949
|
+
_dispatchProxyNow(ws, id, action, msg.params);
|
|
950
|
+
},
|
|
951
|
+
onTimeout() {
|
|
952
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
953
|
+
ws.send(JSON.stringify({
|
|
954
|
+
id,
|
|
955
|
+
success: false,
|
|
956
|
+
error: `[queue_timeout action=${action}] Command queued too long; dropped to prevent ghost execution.`,
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
}
|
|
560
962
|
return;
|
|
561
963
|
}
|
|
562
964
|
|
|
@@ -650,7 +1052,16 @@ function setupPrimaryWss(wss) {
|
|
|
650
1052
|
clearTimeout(cmd.timer);
|
|
651
1053
|
pendingCommands.delete(msg.id);
|
|
652
1054
|
|
|
1055
|
+
// Every reply frees a slot; try to drain the queue before doing
|
|
1056
|
+
// anything else so the next command lands on the extension with
|
|
1057
|
+
// minimum latency.
|
|
1058
|
+
extensionInFlight--;
|
|
1059
|
+
_tryDequeue();
|
|
1060
|
+
|
|
653
1061
|
if (cmd.isProxy) {
|
|
1062
|
+
// Idle time counts from the reply, so a long command cannot leave
|
|
1063
|
+
// its Secondary already due for eviction the moment it finishes.
|
|
1064
|
+
cmd.sourceWs.lastCommandAt = Date.now();
|
|
654
1065
|
if (cmd.sourceWs.readyState === WebSocket.OPEN) {
|
|
655
1066
|
cmd.sourceWs.send(JSON.stringify(msg));
|
|
656
1067
|
}
|
|
@@ -714,14 +1125,27 @@ async function waitForConnection(timeoutMs = 5000) {
|
|
|
714
1125
|
// over a socket instead of settling a promise, so it stays where it is and
|
|
715
1126
|
// shares only the constant.
|
|
716
1127
|
function awaitReply(envelope, { timedOut, sendFailed }) {
|
|
717
|
-
const { id } = envelope;
|
|
1128
|
+
const { id, action } = envelope;
|
|
718
1129
|
return new Promise((resolve, reject) => {
|
|
719
|
-
|
|
1130
|
+
let timer = setTimeout(() => {
|
|
720
1131
|
pendingCommands.delete(id);
|
|
721
1132
|
reject(new Error(timedOut));
|
|
722
1133
|
}, COMMAND_TIMEOUT_MS);
|
|
723
1134
|
|
|
724
|
-
|
|
1135
|
+
// Re-arm the timer when the Primary sends a `queued` or `dispatched`
|
|
1136
|
+
// frame (U11). The frame means the Primary is actively working on this
|
|
1137
|
+
// command and extends the budget by a full COMMAND_TIMEOUT_MS from now.
|
|
1138
|
+
const resetTimer = () => {
|
|
1139
|
+
clearTimeout(timer);
|
|
1140
|
+
timer = setTimeout(() => {
|
|
1141
|
+
pendingCommands.delete(id);
|
|
1142
|
+
reject(new Error(timedOut));
|
|
1143
|
+
}, COMMAND_TIMEOUT_MS);
|
|
1144
|
+
const entry = pendingCommands.get(id);
|
|
1145
|
+
if (entry) entry.timer = timer;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
pendingCommands.set(id, { resolve, reject, timer, action, resetTimer });
|
|
725
1149
|
|
|
726
1150
|
try {
|
|
727
1151
|
extensionSocket.send(JSON.stringify(envelope));
|
|
@@ -749,7 +1173,18 @@ function _buildEnvelope(action, params, activeNodeId) {
|
|
|
749
1173
|
|
|
750
1174
|
// Helper to send command to the extension
|
|
751
1175
|
async function sendCommand(action, params) {
|
|
752
|
-
|
|
1176
|
+
// Evicted, or a caller ahead of this one is already reconnecting: wait for
|
|
1177
|
+
// that one dial rather than racing it.
|
|
1178
|
+
if (isEvicted || reconnect) await lazyReconnect();
|
|
1179
|
+
|
|
1180
|
+
let isConnected = await waitForConnection(5000);
|
|
1181
|
+
|
|
1182
|
+
// During the wait the Primary may have evicted us (4001) or a concurrent
|
|
1183
|
+
// dial may have started; take the lazy path rather than erroring immediately.
|
|
1184
|
+
if (!isConnected && (isEvicted || reconnect)) {
|
|
1185
|
+
await lazyReconnect();
|
|
1186
|
+
isConnected = await waitForConnection(5000);
|
|
1187
|
+
}
|
|
753
1188
|
|
|
754
1189
|
if (isSecondary) {
|
|
755
1190
|
if (!isConnected) {
|
|
@@ -789,13 +1224,57 @@ async function sendCommand(action, params) {
|
|
|
789
1224
|
);
|
|
790
1225
|
}
|
|
791
1226
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1227
|
+
const envelope = _buildEnvelope(action, params, nodeId);
|
|
1228
|
+
const timedOut = `Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`;
|
|
1229
|
+
const sendFailed = (e) => `Failed to send command over WebSocket: ${e.message}`;
|
|
1230
|
+
|
|
1231
|
+
// Primary's own commands share the concurrency cap and the round-robin
|
|
1232
|
+
// queue with proxy commands. The timer is armed at dispatch, not enqueue,
|
|
1233
|
+
// so the budget does not shrink while the command waits for a free slot.
|
|
1234
|
+
if (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
|
|
1235
|
+
extensionInFlight++;
|
|
1236
|
+
return _awaitPrimaryReply(envelope, { timedOut, sendFailed });
|
|
1237
|
+
}
|
|
1238
|
+
return new Promise((resolve, reject) => {
|
|
1239
|
+
const ackSentAt = Date.now();
|
|
1240
|
+
_rrEnqueue(PRIMARY_SOURCE, {
|
|
1241
|
+
id: envelope.id,
|
|
1242
|
+
action: envelope.action,
|
|
1243
|
+
ackSentAt,
|
|
1244
|
+
callerWs: null, // no secondary WS — primary commands have no caller socket
|
|
1245
|
+
onDispatch() {
|
|
1246
|
+
// extensionInFlight already incremented by _tryDequeue
|
|
1247
|
+
_awaitPrimaryReply(envelope, { timedOut, sendFailed }).then(resolve, reject);
|
|
1248
|
+
},
|
|
1249
|
+
onTimeout() {
|
|
1250
|
+
reject(new Error(timedOut));
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Register, arm, send, and settle one primary-mode command. Called after
|
|
1257
|
+
// extensionInFlight has already been incremented.
|
|
1258
|
+
function _awaitPrimaryReply(envelope, { timedOut, sendFailed }) {
|
|
1259
|
+
const { id, action } = envelope;
|
|
1260
|
+
return new Promise((resolve, reject) => {
|
|
1261
|
+
const timer = setTimeout(() => {
|
|
1262
|
+
pendingCommands.delete(id);
|
|
1263
|
+
extensionInFlight--;
|
|
1264
|
+
_tryDequeue();
|
|
1265
|
+
reject(new Error(timedOut));
|
|
1266
|
+
}, COMMAND_TIMEOUT_MS);
|
|
1267
|
+
pendingCommands.set(id, { resolve, reject, timer, action });
|
|
1268
|
+
try {
|
|
1269
|
+
extensionSocket.send(JSON.stringify(envelope));
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
clearTimeout(timer);
|
|
1272
|
+
pendingCommands.delete(id);
|
|
1273
|
+
extensionInFlight--;
|
|
1274
|
+
_tryDequeue();
|
|
1275
|
+
reject(new Error(sendFailed(err)));
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
799
1278
|
}
|
|
800
1279
|
|
|
801
1280
|
// The Primary's view of which tab the agent is working in, shown in the
|
|
@@ -820,6 +1299,7 @@ module.exports = {
|
|
|
820
1299
|
readNetworkState,
|
|
821
1300
|
noteActiveTab,
|
|
822
1301
|
isSecondary: () => isSecondary,
|
|
1302
|
+
isEvicted: () => isEvicted,
|
|
823
1303
|
extensionVersion: () => extensionVersion,
|
|
824
1304
|
_buildEnvelope,
|
|
825
1305
|
_backoffDelay,
|
package/mcp-server/config.js
CHANGED
|
@@ -58,6 +58,58 @@ function resolveMaxSecondaries(env = process.env) {
|
|
|
58
58
|
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MAX_SECONDARIES;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// How long a v2 Secondary may sit idle (no commands) before the Primary sends
|
|
62
|
+
// close code 4001. Only whole-number milliseconds > 0 are accepted; anything
|
|
63
|
+
// else falls back to the default. 0 would mean "evict immediately", which is
|
|
64
|
+
// not a meaningful setting, so it is treated as invalid.
|
|
65
|
+
const DEFAULT_IDLE_EVICTION_MS = 5 * 60 * 1000; // 5 minutes
|
|
66
|
+
function resolveIdleEvictionMs(env = process.env) {
|
|
67
|
+
const raw = env.POLTERTAB_IDLE_EVICTION_MS;
|
|
68
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_IDLE_EVICTION_MS;
|
|
69
|
+
const n = Number(raw);
|
|
70
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_IDLE_EVICTION_MS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The 35 s command budget. Exposed as an env override so tests can shrink it
|
|
74
|
+
// without waiting for real timeouts. Junk or zero fall back to the default.
|
|
75
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 35000;
|
|
76
|
+
function resolveCommandTimeoutMs(env = process.env) {
|
|
77
|
+
const raw = env.POLTERTAB_COMMAND_TIMEOUT_MS;
|
|
78
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_COMMAND_TIMEOUT_MS;
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_COMMAND_TIMEOUT_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// How many commands the primary may have in flight to the extension at once.
|
|
84
|
+
// 0 would refuse every command; junk falls back to the default.
|
|
85
|
+
const DEFAULT_MAX_EXTENSION_CONCURRENCY = 6;
|
|
86
|
+
function resolveMaxConcurrency(env = process.env) {
|
|
87
|
+
const raw = env.POLTERTAB_MAX_CONCURRENCY;
|
|
88
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_EXTENSION_CONCURRENCY;
|
|
89
|
+
const n = Number(raw);
|
|
90
|
+
return Number.isInteger(n) && n >= 1 ? n : DEFAULT_MAX_EXTENSION_CONCURRENCY;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Per-secondary command queue depth. A command beyond this is refused at once.
|
|
94
|
+
// 0 would refuse every queue attempt; junk falls back.
|
|
95
|
+
const DEFAULT_PER_SECONDARY_QUEUE_DEPTH = 3;
|
|
96
|
+
function resolveQueueDepth(env = process.env) {
|
|
97
|
+
const raw = env.POLTERTAB_QUEUE_DEPTH;
|
|
98
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_PER_SECONDARY_QUEUE_DEPTH;
|
|
99
|
+
const n = Number(raw);
|
|
100
|
+
return Number.isInteger(n) && n >= 1 ? n : DEFAULT_PER_SECONDARY_QUEUE_DEPTH;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// How long a command may sit in the server-side queue before being GC'd.
|
|
104
|
+
// 0 / negative / junk fall back to the default.
|
|
105
|
+
const DEFAULT_QUEUE_TIMEOUT_MS = 60000;
|
|
106
|
+
function resolveQueueTimeoutMs(env = process.env) {
|
|
107
|
+
const raw = env.POLTERTAB_QUEUE_TIMEOUT_MS;
|
|
108
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_QUEUE_TIMEOUT_MS;
|
|
109
|
+
const n = Number(raw);
|
|
110
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_QUEUE_TIMEOUT_MS;
|
|
111
|
+
}
|
|
112
|
+
|
|
61
113
|
module.exports = {
|
|
62
114
|
POLTERTAB_HOME,
|
|
63
115
|
MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
|
|
@@ -66,9 +118,14 @@ module.exports = {
|
|
|
66
118
|
DEFAULT_WS_PORT,
|
|
67
119
|
resolvePort,
|
|
68
120
|
resolveMaxSecondaries,
|
|
121
|
+
resolveIdleEvictionMs,
|
|
122
|
+
resolveCommandTimeoutMs,
|
|
123
|
+
resolveMaxConcurrency,
|
|
124
|
+
resolveQueueDepth,
|
|
125
|
+
resolveQueueTimeoutMs,
|
|
69
126
|
|
|
70
127
|
// See the budget above before changing this.
|
|
71
|
-
COMMAND_TIMEOUT_MS:
|
|
128
|
+
COMMAND_TIMEOUT_MS: resolveCommandTimeoutMs(),
|
|
72
129
|
|
|
73
130
|
// Dead-connection detection. A socket that misses a ping/pong round is
|
|
74
131
|
// terminated so the extension's own reconnect can take over.
|
|
@@ -102,6 +159,16 @@ module.exports = {
|
|
|
102
159
|
// promote.
|
|
103
160
|
MAX_SECONDARIES: resolveMaxSecondaries(),
|
|
104
161
|
|
|
162
|
+
// How long a v2 Secondary may sit idle before the Primary evicts it with
|
|
163
|
+
// close code 4001. Configurable so tests can use a short value (e.g. 300 ms)
|
|
164
|
+
// without slowing the suite.
|
|
165
|
+
IDLE_EVICTION_MS: resolveIdleEvictionMs(),
|
|
166
|
+
|
|
167
|
+
// Command queue (PR-3).
|
|
168
|
+
MAX_EXTENSION_CONCURRENCY: resolveMaxConcurrency(),
|
|
169
|
+
PER_SECONDARY_QUEUE_DEPTH: resolveQueueDepth(),
|
|
170
|
+
QUEUE_TIMEOUT_MS: resolveQueueTimeoutMs(),
|
|
171
|
+
|
|
105
172
|
// Lazy-loaded content arrives after the scroll returns. Long enough for the
|
|
106
173
|
// XHR it triggered to land in the capture buffer, short enough that a
|
|
107
174
|
// scroll-and-read loop is not dominated by waiting.
|
package/package.json
CHANGED