poltertab 1.7.0 → 1.8.0-beta.2
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 +15 -2
- package/mcp-server/bridge.js +771 -62
- package/mcp-server/config.js +95 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,8 +244,21 @@ themselves into **secondary** mode, proxying their commands through the primary,
|
|
|
244
244
|
so concurrent agents share one browser with no extra configuration. The
|
|
245
245
|
primary tags each secondary's commands with its id, and the extension keeps a
|
|
246
246
|
separate current tab per agent, so agents get their own tabs. Up to
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
50 secondaries are accepted (set `POLTERTAB_MAX_SECONDARIES` to change the
|
|
248
|
+
limit). A secondary refused for capacity is told why and retries with backoff
|
|
249
|
+
(2s doubling to 60s) instead of looping. If the primary exits, exactly one
|
|
250
|
+
secondary promotes itself to replace it and the others reconnect to it. The
|
|
251
|
+
extension popup lists whichever agents are connected.
|
|
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.
|
|
249
262
|
|
|
250
263
|
**Changing the WebSocket Port:**
|
|
251
264
|
Only needed when something that *isn't* PolterTab already holds `7822` —
|
package/mcp-server/bridge.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const WebSocket = require("ws");
|
|
14
14
|
const crypto = require("crypto");
|
|
15
|
+
const net = require("net");
|
|
15
16
|
const {
|
|
16
17
|
resolvePort,
|
|
17
18
|
DEFAULT_WS_PORT,
|
|
@@ -23,7 +24,14 @@ const {
|
|
|
23
24
|
NETWORK_MAX_BODY_BYTES,
|
|
24
25
|
PROMOTION_DELAY_MS,
|
|
25
26
|
PROMOTION_JITTER_MS,
|
|
27
|
+
PROMOTION_BACKOFF_INITIAL_MS,
|
|
28
|
+
PROMOTION_BACKOFF_MAX_MS,
|
|
29
|
+
PROMOTION_BACKOFF_JITTER,
|
|
26
30
|
MAX_SECONDARIES,
|
|
31
|
+
IDLE_EVICTION_MS,
|
|
32
|
+
MAX_EXTENSION_CONCURRENCY,
|
|
33
|
+
PER_SECONDARY_QUEUE_DEPTH,
|
|
34
|
+
QUEUE_TIMEOUT_MS,
|
|
27
35
|
POLTERTAB_HOME,
|
|
28
36
|
} = require("./config.js");
|
|
29
37
|
const updates = require("./update-check.js");
|
|
@@ -46,6 +54,114 @@ let isSecondary = false;
|
|
|
46
54
|
let nodeId = null;
|
|
47
55
|
let wss = null;
|
|
48
56
|
let httpServer = null;
|
|
57
|
+
let httpServer6 = null; // IPv6 loopback companion; null if host has no IPv6
|
|
58
|
+
|
|
59
|
+
// Secondary-side retry state. One counter for the whole reconnect/probe/promote
|
|
60
|
+
// cycle, reset only by a Primary's secondary_accepted; `refusal` and
|
|
61
|
+
// `unreachable` say why this node is between connections, so a command sent
|
|
62
|
+
// meanwhile can name the cause instead of a generic "not connected".
|
|
63
|
+
let backoffAttempt = 0;
|
|
64
|
+
let retryTimer = null;
|
|
65
|
+
let refusal = null;
|
|
66
|
+
let unreachable = false;
|
|
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
|
+
}
|
|
49
165
|
|
|
50
166
|
function broadcastSessionState() {
|
|
51
167
|
if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
|
|
@@ -100,23 +216,89 @@ function allowOrigin(info, cb) {
|
|
|
100
216
|
|
|
101
217
|
function setupPrimaryServer() {
|
|
102
218
|
const http = require("http");
|
|
103
|
-
|
|
219
|
+
const handleRequest = (req, res) => {
|
|
220
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const secondaries = [];
|
|
223
|
+
for (const [nid, ws] of secondaryClients.entries()) {
|
|
224
|
+
const srcQueue = queuedBySource.get(ws);
|
|
225
|
+
secondaries.push({
|
|
226
|
+
nodeId: nid,
|
|
227
|
+
version: ws.version || null,
|
|
228
|
+
protocol: ws.supportsNewCloseCodes ? "v2" : "legacy",
|
|
229
|
+
pid: ws.pid || null,
|
|
230
|
+
idle_ms: Math.floor(now - ws.lastCommandAt),
|
|
231
|
+
queued_commands: srcQueue ? srcQueue.length : 0,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
let totalQueued = 0;
|
|
235
|
+
for (const items of queuedBySource.values()) totalQueued += items.length;
|
|
236
|
+
const sorted = waitHistory.slice().sort((a, b) => a - b);
|
|
237
|
+
const pct = (p) => sorted.length ? sorted[Math.floor((sorted.length - 1) * p)] : 0;
|
|
238
|
+
const body = JSON.stringify({
|
|
239
|
+
role: "primary",
|
|
240
|
+
pid: process.pid,
|
|
241
|
+
port: WS_PORT,
|
|
242
|
+
version: OWN_VERSION,
|
|
243
|
+
secondaries,
|
|
244
|
+
queue: {
|
|
245
|
+
in_flight: extensionInFlight,
|
|
246
|
+
max_in_flight: MAX_EXTENSION_CONCURRENCY,
|
|
247
|
+
queued: totalQueued,
|
|
248
|
+
max_queued_per_secondary: PER_SECONDARY_QUEUE_DEPTH,
|
|
249
|
+
wait_percentiles_ms: { p50: pct(0.5), p95: pct(0.95), p99: pct(0.99) },
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
253
|
+
res.end(body);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
res.writeHead(404);
|
|
257
|
+
res.end();
|
|
258
|
+
};
|
|
259
|
+
httpServer = http.createServer(handleRequest);
|
|
104
260
|
|
|
105
261
|
httpServer.on("error", (e) => {
|
|
106
262
|
if (e.code === "EADDRINUSE") {
|
|
107
263
|
console.error(
|
|
108
264
|
`[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
|
|
109
265
|
);
|
|
266
|
+
// A server that never bound is still a handle; every lost promotion race
|
|
267
|
+
// used to leave one behind.
|
|
268
|
+
httpServer.close();
|
|
269
|
+
httpServer = null;
|
|
110
270
|
startSecondaryMode();
|
|
271
|
+
// NICE-2 residual race: a legacy (wildcard-binding) PolterTab that starts
|
|
272
|
+
// in the ms between a successful probe and our listen() call will bind
|
|
273
|
+
// undetected on macOS — SO_REUSEADDR lets 127.0.0.1 bind over 0.0.0.0,
|
|
274
|
+
// so the new loopback Primary dual-binds silently. That window is
|
|
275
|
+
// milliseconds and requires two simultaneous starts; not worth a retry
|
|
276
|
+
// loop, but do not remove the probe and rely on this handler alone.
|
|
111
277
|
} else {
|
|
112
278
|
console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
|
|
113
279
|
}
|
|
114
280
|
});
|
|
115
281
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
282
|
+
// Why probe-first (SHOULD-1 narrative, real reason for this change):
|
|
283
|
+
// PR-3 routes /status to loopback only, so the whole Primary now binds
|
|
284
|
+
// 127.0.0.1 instead of 0.0.0.0. On macOS, libuv sets SO_REUSEADDR, and
|
|
285
|
+
// BSD/macOS lets a specific-address bind succeed silently over a live
|
|
286
|
+
// wildcard listener — so the new loopback-binding Primary would start beside
|
|
287
|
+
// a pre-1.8 (wildcard) PolterTab without getting EADDRINUSE, then steal all
|
|
288
|
+
// loopback traffic. S11 is the regression test: it places a wildcard fixture
|
|
289
|
+
// and asserts the bridge-under-test demotes instead of dual-binding. The
|
|
290
|
+
// EADDRINUSE handler above remains as the fallback for same-host races.
|
|
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, "127.0.0.1", () => {
|
|
120
302
|
wss = new WebSocket.WebSocketServer({
|
|
121
303
|
server: httpServer,
|
|
122
304
|
verifyClient: allowOrigin,
|
|
@@ -151,8 +333,65 @@ function setupPrimaryServer() {
|
|
|
151
333
|
});
|
|
152
334
|
}, HEARTBEAT_MS);
|
|
153
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
|
+
|
|
154
391
|
wss.on("close", () => {
|
|
155
392
|
clearInterval(interval);
|
|
393
|
+
clearInterval(evictionInterval);
|
|
394
|
+
clearInterval(queueGcInterval);
|
|
156
395
|
});
|
|
157
396
|
|
|
158
397
|
// Network State Garbage Collector (TTL: 5 minutes)
|
|
@@ -169,12 +408,46 @@ function setupPrimaryServer() {
|
|
|
169
408
|
}, NETWORK_GC_INTERVAL_MS);
|
|
170
409
|
|
|
171
410
|
setupPrimaryWss(wss);
|
|
411
|
+
|
|
412
|
+
// Also listen on ::1 (IPv6 loopback) so Node 18 clients work: Node 18
|
|
413
|
+
// resolves 'localhost' to ::1 first with no happy-eyeballs fallback.
|
|
414
|
+
// Skip cleanly when the host has no IPv6 stack (EADDRNOTAVAIL/EAFNOSUPPORT).
|
|
415
|
+
// On macOS, SO_REUSEADDR lets a specific-address ::1 bind succeed silently
|
|
416
|
+
// over a live :: wildcard listener — but we only set up httpServer6 AFTER
|
|
417
|
+
// probing 127.0.0.1, so a false-primary that owns :: would be caught by that
|
|
418
|
+
// probe (it also owns 127.0.0.1) and we would have gone Secondary already.
|
|
419
|
+
//
|
|
420
|
+
// The "listening" log is delayed until ::1 is ready (or skipped) so that
|
|
421
|
+
// any client connecting on the log signal can reach both addresses.
|
|
422
|
+
const SKIP_IPV6 = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EPROTONOSUPPORT"]);
|
|
423
|
+
const logListening = () =>
|
|
424
|
+
console.error(
|
|
425
|
+
`[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
|
|
426
|
+
);
|
|
427
|
+
httpServer6 = http.createServer(handleRequest);
|
|
428
|
+
httpServer6.on("error", (e) => {
|
|
429
|
+
if (SKIP_IPV6.has(e.code)) {
|
|
430
|
+
httpServer6 = null;
|
|
431
|
+
} else {
|
|
432
|
+
console.error(`[PolterTab MCP] IPv6 loopback error: ${e.message}`);
|
|
433
|
+
}
|
|
434
|
+
logListening();
|
|
435
|
+
});
|
|
436
|
+
httpServer6.once("listening", logListening);
|
|
437
|
+
httpServer6.on("upgrade", (req, socket, head) => {
|
|
438
|
+
wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
|
|
439
|
+
});
|
|
440
|
+
httpServer6.listen(WS_PORT, "::1");
|
|
441
|
+
});
|
|
172
442
|
});
|
|
173
443
|
}
|
|
174
444
|
|
|
175
445
|
function startSecondaryMode() {
|
|
176
446
|
isSecondary = true;
|
|
177
|
-
|
|
447
|
+
// Once per process. The extension keys this agent's current tab by nodeId,
|
|
448
|
+
// so a fresh one after a lost promotion race orphaned that pointer and the
|
|
449
|
+
// agent's next untargeted command landed on the user's focused tab.
|
|
450
|
+
if (!nodeId) nodeId = crypto.randomUUID();
|
|
178
451
|
console.error(
|
|
179
452
|
`[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
|
|
180
453
|
);
|
|
@@ -182,19 +455,150 @@ function startSecondaryMode() {
|
|
|
182
455
|
connectToPrimary();
|
|
183
456
|
}
|
|
184
457
|
|
|
458
|
+
// The retry schedule: INITIAL × 2^attempt, capped, ±JITTER so a crowd of
|
|
459
|
+
// refused Secondaries does not come back in step. `rand` is injectable for
|
|
460
|
+
// the tests.
|
|
461
|
+
function _backoffDelay(attempt, rand = Math.random) {
|
|
462
|
+
const base = Math.min(
|
|
463
|
+
PROMOTION_BACKOFF_INITIAL_MS * 2 ** attempt,
|
|
464
|
+
PROMOTION_BACKOFF_MAX_MS,
|
|
465
|
+
);
|
|
466
|
+
const jitter = 1 + PROMOTION_BACKOFF_JITTER * (2 * rand() - 1);
|
|
467
|
+
return Math.min(Math.round(base * jitter), PROMOTION_BACKOFF_MAX_MS);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function scheduleRetry(fn, delay) {
|
|
471
|
+
clearTimeout(retryTimer);
|
|
472
|
+
retryTimer = setTimeout(fn, delay);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Dial the Primary again after an eviction. Settles on secondary_accepted, not
|
|
476
|
+
// on the socket opening: a command sent before the ACK can land on a Primary
|
|
477
|
+
// that is about to refuse this node. A failed dial rejects at once with the
|
|
478
|
+
// cause (the close handler calls finishReconnect) and leaves the node on PR-1's
|
|
479
|
+
// ordinary schedule — backoff, probe, promote — so the caller is not held for
|
|
480
|
+
// the full command timeout and the port is still taken over if it is free.
|
|
481
|
+
// isEvicted is cleared here rather than on the ACK: from now on the node is
|
|
482
|
+
// reconnecting, and a node that promotes itself must not dial its own port.
|
|
483
|
+
function lazyReconnect() {
|
|
484
|
+
if (reconnect) return reconnect.promise;
|
|
485
|
+
isEvicted = false;
|
|
486
|
+
let settle;
|
|
487
|
+
const promise = new Promise((resolve, reject) => {
|
|
488
|
+
settle = { resolve, reject };
|
|
489
|
+
});
|
|
490
|
+
reconnect = {
|
|
491
|
+
promise,
|
|
492
|
+
...settle,
|
|
493
|
+
timer: setTimeout(
|
|
494
|
+
() => finishReconnect(new Error(reconnectFailed())),
|
|
495
|
+
COMMAND_TIMEOUT_MS,
|
|
496
|
+
),
|
|
497
|
+
};
|
|
498
|
+
connectToPrimary();
|
|
499
|
+
return promise;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function finishReconnect(err) {
|
|
503
|
+
if (!reconnect) return;
|
|
504
|
+
const r = reconnect;
|
|
505
|
+
reconnect = null;
|
|
506
|
+
clearTimeout(r.timer);
|
|
507
|
+
if (err) r.reject(err);
|
|
508
|
+
else r.resolve();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function reconnectFailed() {
|
|
512
|
+
return `[reconnect_failed port=${WS_PORT}] Could not reconnect to primary.`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Is anything listening on the port? Binding to find out is what leaked a
|
|
516
|
+
// server per attempt; a refused TCP connect is the only answer that means the
|
|
517
|
+
// Primary is gone.
|
|
518
|
+
function probePrimary() {
|
|
519
|
+
return new Promise((resolve) => {
|
|
520
|
+
// Dial 127.0.0.1 explicitly; 'localhost' resolves to ::1 first on Node 18
|
|
521
|
+
// (no happy-eyeballs), which would give a false ECONNREFUSED when our own
|
|
522
|
+
// Primary is listening on 127.0.0.1.
|
|
523
|
+
const sock = net.connect(WS_PORT, "127.0.0.1");
|
|
524
|
+
// A listener with a full accept backlog answers with neither connect nor
|
|
525
|
+
// error, which would park this node with no retry scheduled. Treat silence
|
|
526
|
+
// as "something is there": connectToPrimary's handshake timeout then sorts
|
|
527
|
+
// a hung Primary from a live one and keeps the backoff running.
|
|
528
|
+
sock.setTimeout(1000, () => {
|
|
529
|
+
sock.destroy();
|
|
530
|
+
resolve(true);
|
|
531
|
+
});
|
|
532
|
+
sock.once("connect", () => {
|
|
533
|
+
sock.destroy();
|
|
534
|
+
resolve(true);
|
|
535
|
+
});
|
|
536
|
+
sock.once("error", (err) => resolve(err.code !== "ECONNREFUSED"));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function promoteOrRejoin() {
|
|
541
|
+
if (await probePrimary()) {
|
|
542
|
+
connectToPrimary();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
isSecondary = false;
|
|
546
|
+
setupPrimaryServer();
|
|
547
|
+
}
|
|
548
|
+
|
|
185
549
|
function connectToPrimary() {
|
|
186
|
-
|
|
550
|
+
// Dial 127.0.0.1 explicitly so Node 18 (which resolves 'localhost' to ::1
|
|
551
|
+
// first with no fallback) does not get ECONNREFUSED against the IPv4 Primary.
|
|
552
|
+
const ws = new WebSocket(`ws://127.0.0.1:${WS_PORT}`, {
|
|
187
553
|
perMessageDeflate: true,
|
|
554
|
+
// A Primary that accepts TCP but never answers the upgrade (event loop
|
|
555
|
+
// blocked, SIGSTOP) would otherwise hold this node forever.
|
|
556
|
+
handshakeTimeout: 5000,
|
|
188
557
|
});
|
|
558
|
+
extensionSocket = ws;
|
|
559
|
+
let opened = false;
|
|
560
|
+
let accepted = false;
|
|
561
|
+
let errCode = null;
|
|
189
562
|
|
|
190
|
-
|
|
563
|
+
ws.on("open", () => {
|
|
564
|
+
opened = true;
|
|
191
565
|
console.error("[PolterTab MCP] Connected to Primary MCP Server.");
|
|
192
|
-
|
|
566
|
+
// The version is what tells a Primary this node understands 4xxx closes.
|
|
567
|
+
ws.send(
|
|
568
|
+
JSON.stringify({
|
|
569
|
+
type: "secondary_mcp",
|
|
570
|
+
nodeId,
|
|
571
|
+
version: OWN_VERSION,
|
|
572
|
+
pid: process.pid,
|
|
573
|
+
parentPid: process.ppid,
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
193
576
|
});
|
|
194
577
|
|
|
195
|
-
|
|
578
|
+
ws.on("message", (message) => {
|
|
196
579
|
try {
|
|
197
580
|
const msg = JSON.parse(message);
|
|
581
|
+
// Frames about this connection carry a type, never a command id, and
|
|
582
|
+
// are handled before the id branch so none can settle a pending command.
|
|
583
|
+
if (msg.type === "secondary_accepted") {
|
|
584
|
+
accepted = true;
|
|
585
|
+
backoffAttempt = 0;
|
|
586
|
+
refusal = null;
|
|
587
|
+
unreachable = false;
|
|
588
|
+
finishReconnect();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// `queued` and `dispatched` frames re-arm the awaitReply timer (U11)
|
|
592
|
+
// but never settle the command — the reply does that.
|
|
593
|
+
if (
|
|
594
|
+
(msg.type === "queued" || msg.type === "dispatched") &&
|
|
595
|
+
msg.id &&
|
|
596
|
+
pendingCommands.has(msg.id)
|
|
597
|
+
) {
|
|
598
|
+
const entry = pendingCommands.get(msg.id);
|
|
599
|
+
if (entry && entry.resetTimer) entry.resetTimer();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
198
602
|
if (msg.id && pendingCommands.has(msg.id)) {
|
|
199
603
|
const { resolve, reject, timer } = pendingCommands.get(msg.id);
|
|
200
604
|
clearTimeout(timer);
|
|
@@ -214,30 +618,100 @@ function connectToPrimary() {
|
|
|
214
618
|
}
|
|
215
619
|
});
|
|
216
620
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
621
|
+
ws.on("close", (code, reason) => {
|
|
622
|
+
if (extensionSocket === ws) extensionSocket = null;
|
|
623
|
+
// Each branch below sets the one that applies; neither may outlive the
|
|
624
|
+
// close that caused it.
|
|
625
|
+
refusal = null;
|
|
626
|
+
unreachable = false;
|
|
627
|
+
|
|
628
|
+
const refused = code >= 4000 && code <= 4999;
|
|
629
|
+
let info = {};
|
|
630
|
+
if (refused) {
|
|
631
|
+
try {
|
|
632
|
+
info = JSON.parse(reason.toString());
|
|
633
|
+
} catch (_) {}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// A lazy reconnect whose dial ended before the ACK fails now, with the
|
|
637
|
+
// reason when the Primary gave one. The branches below still run, so the
|
|
638
|
+
// node retries or promotes exactly as it would have without the caller.
|
|
639
|
+
if (!accepted) {
|
|
640
|
+
finishReconnect(
|
|
641
|
+
new Error(
|
|
642
|
+
info.reason === "capacity_rejected"
|
|
643
|
+
? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) has ${info.limit} secondaries. Retry later.`
|
|
644
|
+
: reconnectFailed(),
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 4001 = idle_evicted. Park: no reconnect, no probe, no promotion — the
|
|
650
|
+
// next sendCommand dials. A command already on the wire when the Primary
|
|
651
|
+
// closed is lost, and retrying it could run it twice, so say so (R-S2).
|
|
652
|
+
if (code === 4001) {
|
|
653
|
+
isEvicted = true;
|
|
654
|
+
for (const [, { reject, timer, action }] of pendingCommands) {
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
reject(
|
|
657
|
+
new Error(
|
|
658
|
+
`[evicted_mid_command action=${action || "unknown"}] Session evicted while command was in progress. Not auto-retried.`,
|
|
659
|
+
),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
pendingCommands.clear();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
222
665
|
|
|
223
666
|
// Reject all pending commands
|
|
667
|
+
const aborted =
|
|
668
|
+
info.reason === "capacity_rejected"
|
|
669
|
+
? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) refused this node. Command aborted.`
|
|
670
|
+
: "Primary node disconnected. Command aborted.";
|
|
224
671
|
for (const [id, { reject, timer }] of pendingCommands) {
|
|
225
672
|
clearTimeout(timer);
|
|
226
|
-
reject(new Error(
|
|
673
|
+
reject(new Error(aborted));
|
|
227
674
|
pendingCommands.delete(id);
|
|
228
675
|
}
|
|
229
676
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
677
|
+
// Refused by a live Primary: it is not dying, so promoting can only fail.
|
|
678
|
+
if (refused) {
|
|
679
|
+
const delay = _backoffDelay(backoffAttempt++);
|
|
680
|
+
refusal = { ...info, retryAt: Date.now() + delay };
|
|
681
|
+
console.error(
|
|
682
|
+
`[PolterTab MCP] Primary refused this node (${code} ${info.reason || "unknown"}). Retrying as Secondary in ${delay}ms.`,
|
|
683
|
+
);
|
|
684
|
+
scheduleRetry(connectToPrimary, delay);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Something holds the port but never completed the handshake: a hung
|
|
689
|
+
// Primary, or not a PolterTab one. Promoting would only hit EADDRINUSE.
|
|
690
|
+
if (!opened && errCode !== "ECONNREFUSED") {
|
|
691
|
+
const delay = _backoffDelay(backoffAttempt++);
|
|
692
|
+
unreachable = true;
|
|
693
|
+
console.error(
|
|
694
|
+
`[PolterTab MCP] Primary on port ${WS_PORT} is not responding (${errCode || code}). Retrying in ${delay}ms.`,
|
|
695
|
+
);
|
|
696
|
+
scheduleRetry(connectToPrimary, delay);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
233
699
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
700
|
+
// The Primary went away, or closed this node without saying why (as a
|
|
701
|
+
// pre-1.8 Primary does at its limit). Probe first; promote only if the port
|
|
702
|
+
// is free. A node that was never accepted stays on the backoff schedule, so
|
|
703
|
+
// even a codeless refusal cannot loop at full speed.
|
|
704
|
+
const delay = accepted
|
|
705
|
+
? PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS)
|
|
706
|
+
: _backoffDelay(backoffAttempt++);
|
|
707
|
+
console.error(
|
|
708
|
+
`[PolterTab MCP] Connection to Primary lost. Attempting to become Primary in ${delay}ms...`,
|
|
709
|
+
);
|
|
710
|
+
scheduleRetry(promoteOrRejoin, delay);
|
|
238
711
|
});
|
|
239
712
|
|
|
240
|
-
|
|
713
|
+
ws.on("error", (err) => {
|
|
714
|
+
errCode = err.code || null;
|
|
241
715
|
console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
|
|
242
716
|
});
|
|
243
717
|
}
|
|
@@ -267,9 +741,47 @@ async function readNetworkState(params = {}) {
|
|
|
267
741
|
return { tabId, capturedRequests: data.length, data };
|
|
268
742
|
}
|
|
269
743
|
|
|
744
|
+
// Dispatch a proxy command to the extension. Must be called AFTER
|
|
745
|
+
// extensionInFlight has already been incremented by the caller. Arms the
|
|
746
|
+
// command timeout; on either a reply or a timeout, decrements extensionInFlight
|
|
747
|
+
// and drains the queue so the next waiting command gets its turn.
|
|
748
|
+
function _dispatchProxyNow(callerWs, id, action, params) {
|
|
749
|
+
const timer = setTimeout(() => {
|
|
750
|
+
pendingCommands.delete(id);
|
|
751
|
+
extensionInFlight--;
|
|
752
|
+
_tryDequeue();
|
|
753
|
+
if (callerWs.readyState === WebSocket.OPEN) {
|
|
754
|
+
callerWs.send(
|
|
755
|
+
JSON.stringify({ id, success: false, error: `Command '${action}' timed out.` }),
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
}, COMMAND_TIMEOUT_MS);
|
|
759
|
+
pendingCommands.set(id, { isProxy: true, sourceWs: callerWs, timer });
|
|
760
|
+
// Belt-and-braces: _tryDequeue's open-check guards the common case; this
|
|
761
|
+
// catches the narrow race where the extension socket closes between that
|
|
762
|
+
// check and the actual send (both are synchronous, but the close event is
|
|
763
|
+
// delivered between event-loop ticks, so the race is real on high-load paths).
|
|
764
|
+
try {
|
|
765
|
+
extensionSocket.send(
|
|
766
|
+
JSON.stringify({ ..._buildEnvelope(action, params, callerWs.nodeId), id }),
|
|
767
|
+
);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
clearTimeout(timer);
|
|
770
|
+
pendingCommands.delete(id);
|
|
771
|
+
extensionInFlight--;
|
|
772
|
+
_tryDequeue();
|
|
773
|
+
if (callerWs.readyState === WebSocket.OPEN) {
|
|
774
|
+
callerWs.send(
|
|
775
|
+
JSON.stringify({ id, success: false, error: "Browser extension not connected to Primary." }),
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
270
781
|
function setupPrimaryWss(wss) {
|
|
271
782
|
wss.on("connection", (ws) => {
|
|
272
783
|
ws.isAlive = true;
|
|
784
|
+
ws.supportsNewCloseCodes = false;
|
|
273
785
|
ws.on("pong", () => {
|
|
274
786
|
ws.isAlive = true;
|
|
275
787
|
});
|
|
@@ -279,18 +791,56 @@ function setupPrimaryWss(wss) {
|
|
|
279
791
|
const msg = JSON.parse(message);
|
|
280
792
|
|
|
281
793
|
if (msg.type === "secondary_mcp") {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
794
|
+
// Only a Secondary that names its version understands a 4xxx close.
|
|
795
|
+
// An older one answers any close by trying to promote — the loop this
|
|
796
|
+
// limit used to trigger — so it is never refused and never counted.
|
|
797
|
+
ws.supportsNewCloseCodes =
|
|
798
|
+
typeof msg.version === "string" && msg.version !== "";
|
|
799
|
+
if (ws.supportsNewCloseCodes) {
|
|
800
|
+
let v2 = 0;
|
|
801
|
+
for (const [id, c] of secondaryClients) {
|
|
802
|
+
if (id !== msg.nodeId && c.supportsNewCloseCodes) v2++;
|
|
803
|
+
}
|
|
804
|
+
if (v2 >= MAX_SECONDARIES) {
|
|
805
|
+
console.error(
|
|
806
|
+
`[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
|
|
807
|
+
);
|
|
808
|
+
ws.close(
|
|
809
|
+
4000,
|
|
810
|
+
JSON.stringify({
|
|
811
|
+
reason: "capacity_rejected",
|
|
812
|
+
limit: MAX_SECONDARIES,
|
|
813
|
+
retry_in_ms: PROMOTION_BACKOFF_INITIAL_MS,
|
|
814
|
+
pid: process.pid,
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// Same node, new socket: the old one is dead weight the client may
|
|
821
|
+
// never end, and the close guard below keeps its close harmless.
|
|
822
|
+
const displaced = secondaryClients.get(msg.nodeId);
|
|
823
|
+
if (displaced && displaced !== ws && displaced.readyState === WebSocket.OPEN) {
|
|
824
|
+
displaced.close(1000);
|
|
288
825
|
}
|
|
289
826
|
secondaryClients.set(msg.nodeId, ws);
|
|
290
827
|
ws.nodeId = msg.nodeId;
|
|
828
|
+
ws.version = msg.version || null;
|
|
829
|
+
ws.pid = msg.pid || null;
|
|
830
|
+
ws.parentPid = msg.parentPid || null;
|
|
831
|
+
ws.lastCommandAt = Date.now();
|
|
291
832
|
console.error(
|
|
292
833
|
`[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
|
|
293
834
|
);
|
|
835
|
+
if (ws.supportsNewCloseCodes) {
|
|
836
|
+
ws.send(
|
|
837
|
+
JSON.stringify({
|
|
838
|
+
type: "secondary_accepted",
|
|
839
|
+
nodeId: msg.nodeId,
|
|
840
|
+
primaryVersion: OWN_VERSION,
|
|
841
|
+
}),
|
|
842
|
+
);
|
|
843
|
+
}
|
|
294
844
|
broadcastSessionState();
|
|
295
845
|
return;
|
|
296
846
|
}
|
|
@@ -302,6 +852,25 @@ function setupPrimaryWss(wss) {
|
|
|
302
852
|
|
|
303
853
|
if (msg.type === "proxy_command") {
|
|
304
854
|
const { id, action } = msg;
|
|
855
|
+
// No nodeId means no secondary_mcp yet — or a socket refused with
|
|
856
|
+
// 4000 an instant ago. The extension would bucket its command as the
|
|
857
|
+
// Primary agent's and move that agent's current tab; a refused
|
|
858
|
+
// socket would not even get the reply. A well-behaved client's
|
|
859
|
+
// handshake always precedes its first command on the same socket.
|
|
860
|
+
if (!ws.nodeId) {
|
|
861
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
862
|
+
ws.send(
|
|
863
|
+
JSON.stringify({
|
|
864
|
+
id,
|
|
865
|
+
success: false,
|
|
866
|
+
error: "[not_registered] Send secondary_mcp before proxy_command.",
|
|
867
|
+
}),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// Each command restarts this Secondary's idle clock.
|
|
873
|
+
ws.lastCommandAt = Date.now();
|
|
305
874
|
// Stamp which agent is asking, from the socket rather than the
|
|
306
875
|
// message, so the extension keeps this Secondary's current tab apart
|
|
307
876
|
// from the Primary's and every other Secondary's.
|
|
@@ -345,29 +914,73 @@ function setupPrimaryWss(wss) {
|
|
|
345
914
|
return;
|
|
346
915
|
}
|
|
347
916
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
917
|
+
// Legacy secondaries bypass the queue: they cannot receive `queued`
|
|
918
|
+
// or `dispatched` frames, so they go straight to the extension and
|
|
919
|
+
// keep today's timing contract. They still count toward extensionInFlight
|
|
920
|
+
// so v2 commands behind them wait their turn.
|
|
921
|
+
if (!ws.supportsNewCloseCodes) {
|
|
922
|
+
extensionInFlight++;
|
|
923
|
+
const timer = setTimeout(() => {
|
|
924
|
+
pendingCommands.delete(id);
|
|
925
|
+
extensionInFlight--;
|
|
926
|
+
_tryDequeue();
|
|
927
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
928
|
+
ws.send(JSON.stringify({ id, success: false, error: `Command '${action}' timed out.` }));
|
|
929
|
+
}
|
|
930
|
+
}, COMMAND_TIMEOUT_MS);
|
|
931
|
+
pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
|
|
932
|
+
extensionSocket.send(JSON.stringify({ ..._buildEnvelope(action, msg.params, ws.nodeId), id }));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// v2 Secondary: use the concurrency cap and queue.
|
|
937
|
+
if (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
|
|
938
|
+
// Free slot: dispatch immediately. No queued frame.
|
|
939
|
+
extensionInFlight++;
|
|
940
|
+
_dispatchProxyNow(ws, id, action, msg.params);
|
|
941
|
+
} else {
|
|
942
|
+
// Slot full: enqueue if there is room in this secondary's queue.
|
|
943
|
+
const srcQueue = queuedBySource.get(ws);
|
|
944
|
+
const depth = srcQueue ? srcQueue.length : 0;
|
|
945
|
+
if (depth >= PER_SECONDARY_QUEUE_DEPTH) {
|
|
946
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
947
|
+
ws.send(JSON.stringify({
|
|
353
948
|
id,
|
|
354
949
|
success: false,
|
|
355
|
-
error: `
|
|
356
|
-
})
|
|
357
|
-
|
|
950
|
+
error: `[queue_full action=${action}] Per-secondary queue depth (${PER_SECONDARY_QUEUE_DEPTH}) reached. Try again shortly.`,
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const ackSentAt = Date.now();
|
|
956
|
+
const position = depth + 1;
|
|
957
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
958
|
+
ws.send(JSON.stringify({ type: "queued", id, position }));
|
|
358
959
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
|
|
362
|
-
// Same envelope construction as the direct path, so a tool argument
|
|
363
|
-
// named id or action cannot override the routing here either. The
|
|
364
|
-
// Primary's own id is kept last: pendingCommands is keyed on it.
|
|
365
|
-
extensionSocket.send(
|
|
366
|
-
JSON.stringify({
|
|
367
|
-
..._buildEnvelope(action, msg.params, ws.nodeId),
|
|
960
|
+
_rrEnqueue(ws, {
|
|
368
961
|
id,
|
|
369
|
-
|
|
370
|
-
|
|
962
|
+
action,
|
|
963
|
+
params: msg.params,
|
|
964
|
+
nodeId: ws.nodeId,
|
|
965
|
+
ackSentAt,
|
|
966
|
+
callerWs: ws,
|
|
967
|
+
onDispatch() {
|
|
968
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
969
|
+
ws.send(JSON.stringify({ type: "dispatched", id }));
|
|
970
|
+
}
|
|
971
|
+
_dispatchProxyNow(ws, id, action, msg.params);
|
|
972
|
+
},
|
|
973
|
+
onTimeout() {
|
|
974
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
975
|
+
ws.send(JSON.stringify({
|
|
976
|
+
id,
|
|
977
|
+
success: false,
|
|
978
|
+
error: `[queue_timeout action=${action}] Command queued too long; dropped to prevent ghost execution.`,
|
|
979
|
+
}));
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
}
|
|
371
984
|
return;
|
|
372
985
|
}
|
|
373
986
|
|
|
@@ -461,7 +1074,16 @@ function setupPrimaryWss(wss) {
|
|
|
461
1074
|
clearTimeout(cmd.timer);
|
|
462
1075
|
pendingCommands.delete(msg.id);
|
|
463
1076
|
|
|
1077
|
+
// Every reply frees a slot; try to drain the queue before doing
|
|
1078
|
+
// anything else so the next command lands on the extension with
|
|
1079
|
+
// minimum latency.
|
|
1080
|
+
extensionInFlight--;
|
|
1081
|
+
_tryDequeue();
|
|
1082
|
+
|
|
464
1083
|
if (cmd.isProxy) {
|
|
1084
|
+
// Idle time counts from the reply, so a long command cannot leave
|
|
1085
|
+
// its Secondary already due for eviction the moment it finishes.
|
|
1086
|
+
cmd.sourceWs.lastCommandAt = Date.now();
|
|
465
1087
|
if (cmd.sourceWs.readyState === WebSocket.OPEN) {
|
|
466
1088
|
cmd.sourceWs.send(JSON.stringify(msg));
|
|
467
1089
|
}
|
|
@@ -481,8 +1103,12 @@ function setupPrimaryWss(wss) {
|
|
|
481
1103
|
console.error(
|
|
482
1104
|
`[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
|
|
483
1105
|
);
|
|
484
|
-
|
|
485
|
-
|
|
1106
|
+
// A node now keeps its id across reconnects, so its new socket may
|
|
1107
|
+
// already be registered by the time the old one's close arrives.
|
|
1108
|
+
if (secondaryClients.get(ws.nodeId) === ws) {
|
|
1109
|
+
secondaryClients.delete(ws.nodeId);
|
|
1110
|
+
broadcastSessionState();
|
|
1111
|
+
}
|
|
486
1112
|
} else if (extensionSocket === ws) {
|
|
487
1113
|
console.error("[PolterTab MCP] Chrome extension disconnected.");
|
|
488
1114
|
extensionSocket = null;
|
|
@@ -499,6 +1125,7 @@ function shutdown() {
|
|
|
499
1125
|
console.error("[PolterTab MCP] Shutting down...");
|
|
500
1126
|
if (wss) wss.close();
|
|
501
1127
|
if (httpServer) httpServer.close();
|
|
1128
|
+
if (httpServer6) httpServer6.close();
|
|
502
1129
|
process.exit(0);
|
|
503
1130
|
}
|
|
504
1131
|
// Helper to wait for WebSocket connection
|
|
@@ -521,14 +1148,27 @@ async function waitForConnection(timeoutMs = 5000) {
|
|
|
521
1148
|
// over a socket instead of settling a promise, so it stays where it is and
|
|
522
1149
|
// shares only the constant.
|
|
523
1150
|
function awaitReply(envelope, { timedOut, sendFailed }) {
|
|
524
|
-
const { id } = envelope;
|
|
1151
|
+
const { id, action } = envelope;
|
|
525
1152
|
return new Promise((resolve, reject) => {
|
|
526
|
-
|
|
1153
|
+
let timer = setTimeout(() => {
|
|
527
1154
|
pendingCommands.delete(id);
|
|
528
1155
|
reject(new Error(timedOut));
|
|
529
1156
|
}, COMMAND_TIMEOUT_MS);
|
|
530
1157
|
|
|
531
|
-
|
|
1158
|
+
// Re-arm the timer when the Primary sends a `queued` or `dispatched`
|
|
1159
|
+
// frame (U11). The frame means the Primary is actively working on this
|
|
1160
|
+
// command and extends the budget by a full COMMAND_TIMEOUT_MS from now.
|
|
1161
|
+
const resetTimer = () => {
|
|
1162
|
+
clearTimeout(timer);
|
|
1163
|
+
timer = setTimeout(() => {
|
|
1164
|
+
pendingCommands.delete(id);
|
|
1165
|
+
reject(new Error(timedOut));
|
|
1166
|
+
}, COMMAND_TIMEOUT_MS);
|
|
1167
|
+
const entry = pendingCommands.get(id);
|
|
1168
|
+
if (entry) entry.timer = timer;
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
pendingCommands.set(id, { resolve, reject, timer, action, resetTimer });
|
|
532
1172
|
|
|
533
1173
|
try {
|
|
534
1174
|
extensionSocket.send(JSON.stringify(envelope));
|
|
@@ -556,10 +1196,32 @@ function _buildEnvelope(action, params, activeNodeId) {
|
|
|
556
1196
|
|
|
557
1197
|
// Helper to send command to the extension
|
|
558
1198
|
async function sendCommand(action, params) {
|
|
559
|
-
|
|
1199
|
+
// Evicted, or a caller ahead of this one is already reconnecting: wait for
|
|
1200
|
+
// that one dial rather than racing it.
|
|
1201
|
+
if (isEvicted || reconnect) await lazyReconnect();
|
|
1202
|
+
|
|
1203
|
+
let isConnected = await waitForConnection(5000);
|
|
1204
|
+
|
|
1205
|
+
// During the wait the Primary may have evicted us (4001) or a concurrent
|
|
1206
|
+
// dial may have started; take the lazy path rather than erroring immediately.
|
|
1207
|
+
if (!isConnected && (isEvicted || reconnect)) {
|
|
1208
|
+
await lazyReconnect();
|
|
1209
|
+
isConnected = await waitForConnection(5000);
|
|
1210
|
+
}
|
|
560
1211
|
|
|
561
1212
|
if (isSecondary) {
|
|
562
1213
|
if (!isConnected) {
|
|
1214
|
+
if (refusal && refusal.reason === "capacity_rejected") {
|
|
1215
|
+
const s = Math.max(0, Math.ceil((refusal.retryAt - Date.now()) / 1000));
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
`[capacity_rejected limit=${refusal.limit}] Primary (pid ${refusal.pid}) has ${refusal.limit} secondaries. Retry in ${s}s.`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
if (unreachable) {
|
|
1221
|
+
throw new Error(
|
|
1222
|
+
`[primary_unreachable port=${WS_PORT}] Primary is not responding. Backing off.`,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
563
1225
|
throw new Error("Secondary node not connected to Primary MCP server.");
|
|
564
1226
|
}
|
|
565
1227
|
|
|
@@ -585,13 +1247,57 @@ async function sendCommand(action, params) {
|
|
|
585
1247
|
);
|
|
586
1248
|
}
|
|
587
1249
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1250
|
+
const envelope = _buildEnvelope(action, params, nodeId);
|
|
1251
|
+
const timedOut = `Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`;
|
|
1252
|
+
const sendFailed = (e) => `Failed to send command over WebSocket: ${e.message}`;
|
|
1253
|
+
|
|
1254
|
+
// Primary's own commands share the concurrency cap and the round-robin
|
|
1255
|
+
// queue with proxy commands. The timer is armed at dispatch, not enqueue,
|
|
1256
|
+
// so the budget does not shrink while the command waits for a free slot.
|
|
1257
|
+
if (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
|
|
1258
|
+
extensionInFlight++;
|
|
1259
|
+
return _awaitPrimaryReply(envelope, { timedOut, sendFailed });
|
|
1260
|
+
}
|
|
1261
|
+
return new Promise((resolve, reject) => {
|
|
1262
|
+
const ackSentAt = Date.now();
|
|
1263
|
+
_rrEnqueue(PRIMARY_SOURCE, {
|
|
1264
|
+
id: envelope.id,
|
|
1265
|
+
action: envelope.action,
|
|
1266
|
+
ackSentAt,
|
|
1267
|
+
callerWs: null, // no secondary WS — primary commands have no caller socket
|
|
1268
|
+
onDispatch() {
|
|
1269
|
+
// extensionInFlight already incremented by _tryDequeue
|
|
1270
|
+
_awaitPrimaryReply(envelope, { timedOut, sendFailed }).then(resolve, reject);
|
|
1271
|
+
},
|
|
1272
|
+
onTimeout() {
|
|
1273
|
+
reject(new Error(timedOut));
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Register, arm, send, and settle one primary-mode command. Called after
|
|
1280
|
+
// extensionInFlight has already been incremented.
|
|
1281
|
+
function _awaitPrimaryReply(envelope, { timedOut, sendFailed }) {
|
|
1282
|
+
const { id, action } = envelope;
|
|
1283
|
+
return new Promise((resolve, reject) => {
|
|
1284
|
+
const timer = setTimeout(() => {
|
|
1285
|
+
pendingCommands.delete(id);
|
|
1286
|
+
extensionInFlight--;
|
|
1287
|
+
_tryDequeue();
|
|
1288
|
+
reject(new Error(timedOut));
|
|
1289
|
+
}, COMMAND_TIMEOUT_MS);
|
|
1290
|
+
pendingCommands.set(id, { resolve, reject, timer, action });
|
|
1291
|
+
try {
|
|
1292
|
+
extensionSocket.send(JSON.stringify(envelope));
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
clearTimeout(timer);
|
|
1295
|
+
pendingCommands.delete(id);
|
|
1296
|
+
extensionInFlight--;
|
|
1297
|
+
_tryDequeue();
|
|
1298
|
+
reject(new Error(sendFailed(err)));
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
595
1301
|
}
|
|
596
1302
|
|
|
597
1303
|
// The Primary's view of which tab the agent is working in, shown in the
|
|
@@ -616,6 +1322,9 @@ module.exports = {
|
|
|
616
1322
|
readNetworkState,
|
|
617
1323
|
noteActiveTab,
|
|
618
1324
|
isSecondary: () => isSecondary,
|
|
1325
|
+
isEvicted: () => isEvicted,
|
|
619
1326
|
extensionVersion: () => extensionVersion,
|
|
620
1327
|
_buildEnvelope,
|
|
1328
|
+
_backoffDelay,
|
|
1329
|
+
_probePrimary: probePrimary,
|
|
621
1330
|
};
|
package/mcp-server/config.js
CHANGED
|
@@ -46,6 +46,70 @@ function resolvePort(argv = process.argv, env = process.env) {
|
|
|
46
46
|
return DEFAULT_WS_PORT;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// The v2 Secondary ceiling: POLTERTAB_MAX_SECONDARIES if it is a whole number
|
|
50
|
+
// ≥ 0, else 50. 0 is honoured — it refuses every versioned Secondary (old ones
|
|
51
|
+
// are still accepted, see MAX_SECONDARIES below) — so an explicit 0 must not
|
|
52
|
+
// fall through to the default the way `parseInt(x) || 50` let it.
|
|
53
|
+
const DEFAULT_MAX_SECONDARIES = 50;
|
|
54
|
+
function resolveMaxSecondaries(env = process.env) {
|
|
55
|
+
const raw = env.POLTERTAB_MAX_SECONDARIES;
|
|
56
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_SECONDARIES;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MAX_SECONDARIES;
|
|
59
|
+
}
|
|
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
|
+
|
|
49
113
|
module.exports = {
|
|
50
114
|
POLTERTAB_HOME,
|
|
51
115
|
MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
|
|
@@ -53,9 +117,15 @@ module.exports = {
|
|
|
53
117
|
|
|
54
118
|
DEFAULT_WS_PORT,
|
|
55
119
|
resolvePort,
|
|
120
|
+
resolveMaxSecondaries,
|
|
121
|
+
resolveIdleEvictionMs,
|
|
122
|
+
resolveCommandTimeoutMs,
|
|
123
|
+
resolveMaxConcurrency,
|
|
124
|
+
resolveQueueDepth,
|
|
125
|
+
resolveQueueTimeoutMs,
|
|
56
126
|
|
|
57
127
|
// See the budget above before changing this.
|
|
58
|
-
COMMAND_TIMEOUT_MS:
|
|
128
|
+
COMMAND_TIMEOUT_MS: resolveCommandTimeoutMs(),
|
|
59
129
|
|
|
60
130
|
// Dead-connection detection. A socket that misses a ping/pong round is
|
|
61
131
|
// terminated so the extension's own reconnect can take over.
|
|
@@ -74,9 +144,30 @@ module.exports = {
|
|
|
74
144
|
PROMOTION_DELAY_MS: 500,
|
|
75
145
|
PROMOTION_JITTER_MS: 2000,
|
|
76
146
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
147
|
+
// A refused or unreachable Secondary retries on this schedule — 2 s, 4 s,
|
|
148
|
+
// 8 s … ±25%, capped — and the whole probe/connect/promote cycle runs under
|
|
149
|
+
// it, reset only when a Primary sends secondary_accepted. Without it a
|
|
150
|
+
// refusal was answered by an immediate reconnect, refused again, forever.
|
|
151
|
+
PROMOTION_BACKOFF_INITIAL_MS: 2000,
|
|
152
|
+
PROMOTION_BACKOFF_MAX_MS: 60000,
|
|
153
|
+
PROMOTION_BACKOFF_JITTER: 0.25,
|
|
154
|
+
|
|
155
|
+
// A safety net against runaway processes, not a usage limit: at 5 it was
|
|
156
|
+
// routinely hit by idle helpers, and every refusal fed the loop above. Only
|
|
157
|
+
// Secondaries that declare a version count; an old one is always accepted,
|
|
158
|
+
// because the only refusal it understands is a close it answers by trying to
|
|
159
|
+
// promote.
|
|
160
|
+
MAX_SECONDARIES: resolveMaxSecondaries(),
|
|
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(),
|
|
80
171
|
|
|
81
172
|
// Lazy-loaded content arrives after the scroll returns. Long enough for the
|
|
82
173
|
// XHR it triggered to land in the capture buffer, short enough that a
|
package/package.json
CHANGED