@debugg-ai/debugg-ai-mcp 3.9.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +37 -1
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +32 -1
- package/dist/handlers/triggerCrawlHandler.js +26 -1
- package/dist/services/caddy/caddyProxy.js +611 -0
- package/dist/services/caddy/portLock.js +321 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/services/verdictAdapter.js +2 -0
- package/dist/utils/telemetry.js +16 -0
- package/dist/utils/tunnelContext.js +52 -15
- package/dist/utils/tunnelDisposition.js +9 -17
- package/package.json +3 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session port route lock — services/caddy/portLock.ts
|
|
3
|
+
*
|
|
4
|
+
* See docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.4 for the
|
|
5
|
+
* full design rationale; this file is a direct implementation of it, not a
|
|
6
|
+
* reinterpretation. Read that section before changing anything here.
|
|
7
|
+
*
|
|
8
|
+
* One Caddy instance holds exactly one dynamic upstream (§2.2). Two calls
|
|
9
|
+
* targeting the SAME port can dispatch fully concurrently (no repoint
|
|
10
|
+
* needed — Caddy is already pointed the right way). Two calls targeting
|
|
11
|
+
* DIFFERENT ports must fully serialize: repoint -> dispatch -> release, for
|
|
12
|
+
* the WHOLE tool call, not just the repoint — a call can hold the tunnel
|
|
13
|
+
* for minutes, and a mid-flight repoint-away would silently misdirect a
|
|
14
|
+
* live browser session into the wrong local app.
|
|
15
|
+
*
|
|
16
|
+
* This lock is instantiated ONCE PER SESSION (per `TunnelInfo`, §2.3), never
|
|
17
|
+
* as a process-wide singleton — a global lock would serialize unrelated
|
|
18
|
+
* callers on totally different Caddy instances (§2.1: one process can host
|
|
19
|
+
* N session keys in HTTP mode, each with its own Caddy instance).
|
|
20
|
+
*
|
|
21
|
+
* The generation identity is `{port, isHttpsLocal}`, not `port` alone — a
|
|
22
|
+
* same-port-but-flipped-`isHttpsLocal` request needs a real repoint too,
|
|
23
|
+
* matching `CaddyProxy.setUpstream`'s own idempotency check (see
|
|
24
|
+
* services/caddy/caddyProxy.ts).
|
|
25
|
+
*
|
|
26
|
+
* Race fix this file exists to encode (§2.4 "Race fix" subsection): EVERY
|
|
27
|
+
* claimer of a generation — whether it creates it or joins it — awaits the
|
|
28
|
+
* exact SAME `readyPromise` (the one real `applyUpstream` call for that
|
|
29
|
+
* generation) before its handle resolves. A synchronously-resolved handle
|
|
30
|
+
* for a joiner would let dispatch start before the repoint is confirmed, or
|
|
31
|
+
* even after it has failed. See `claim()` and §3.4's three-caller walkthrough.
|
|
32
|
+
*
|
|
33
|
+
* "No forced release, ever" (§4): `maxHoldMs` is an OBSERVABILITY-ONLY
|
|
34
|
+
* watchdog. Force-releasing a lock while a legitimately slow call is STILL
|
|
35
|
+
* ACTUALLY USING the port is exactly the traffic-misdirection failure this
|
|
36
|
+
* whole architecture exists to prevent. The watchdog only logs + telemeters;
|
|
37
|
+
* it never clears `gen`.
|
|
38
|
+
*/
|
|
39
|
+
import { logger } from '../../utils/logger.js';
|
|
40
|
+
import { Telemetry, TelemetryEvents } from '../../utils/telemetry.js';
|
|
41
|
+
// ── Errors (house style: named subclasses of Error — see
|
|
42
|
+
// services/tunnels.ts's TunnelProvisionError for the pattern) ────────────
|
|
43
|
+
/** A different-port caller waited past `maxWaitMs` (default 20 min — chosen
|
|
44
|
+
* to comfortably exceed check_app_in_browser's ~720s worst-case backend
|
|
45
|
+
* budget plus retry overhead) without the route becoming free. */
|
|
46
|
+
export class PortRouteQueueTimeoutError extends Error {
|
|
47
|
+
targetPort;
|
|
48
|
+
blockingPort;
|
|
49
|
+
waitedMs;
|
|
50
|
+
constructor(targetPort, blockingPort, waitedMs) {
|
|
51
|
+
const blockingSuffix = blockingPort !== undefined ? ` (currently routed to port ${blockingPort})` : '';
|
|
52
|
+
super(`Timed out after ${waitedMs}ms waiting for the shared local tunnel route to become ` +
|
|
53
|
+
`available for port ${targetPort}${blockingSuffix}`);
|
|
54
|
+
this.name = 'PortRouteQueueTimeoutError';
|
|
55
|
+
this.targetPort = targetPort;
|
|
56
|
+
this.blockingPort = blockingPort;
|
|
57
|
+
this.waitedMs = waitedMs;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** A queued (not-yet-running) caller's `AbortSignal` fired before it was
|
|
61
|
+
* promoted. Never thrown for a call that has already been claimed/promoted
|
|
62
|
+
* — see the class doc comment above `PortLock`. */
|
|
63
|
+
export class PortLockAbortedError extends Error {
|
|
64
|
+
callId;
|
|
65
|
+
targetPort;
|
|
66
|
+
constructor(callId, targetPort) {
|
|
67
|
+
super(`Call ${callId} was aborted while waiting for the shared local tunnel route to port ${targetPort}`);
|
|
68
|
+
this.name = 'PortLockAbortedError';
|
|
69
|
+
this.callId = callId;
|
|
70
|
+
this.targetPort = targetPort;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The `applyUpstream` (i.e. `CaddyProxy.setUpstream`) call backing a
|
|
74
|
+
* generation failed. Every claimer of that generation — the one that
|
|
75
|
+
* created it and every joiner — rejects with this. The lock itself is
|
|
76
|
+
* never left wedged: the generation is torn down and the next queued
|
|
77
|
+
* waiter (if any) is promoted (see `claim()`'s catch branch). */
|
|
78
|
+
export class CaddyRepointError extends Error {
|
|
79
|
+
targetPort;
|
|
80
|
+
constructor(targetPort, cause) {
|
|
81
|
+
super(`Failed to repoint the shared local tunnel route to port ${targetPort}: ${describeCause(cause)}`, {
|
|
82
|
+
cause: cause instanceof Error ? cause : undefined,
|
|
83
|
+
});
|
|
84
|
+
this.name = 'CaddyRepointError';
|
|
85
|
+
this.targetPort = targetPort;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function describeCause(cause) {
|
|
89
|
+
if (cause instanceof Error)
|
|
90
|
+
return cause.message;
|
|
91
|
+
return String(cause);
|
|
92
|
+
}
|
|
93
|
+
function sameTarget(a, b) {
|
|
94
|
+
return a.port === b.port && Boolean(a.isHttpsLocal) === Boolean(b.isHttpsLocal);
|
|
95
|
+
}
|
|
96
|
+
// ── PortLock ─────────────────────────────────────────────────────────────────
|
|
97
|
+
export class PortLock {
|
|
98
|
+
applyUpstream;
|
|
99
|
+
gen = null;
|
|
100
|
+
queue = [];
|
|
101
|
+
/** Above check_app_in_browser's ~720s worst-case backend budget + retry
|
|
102
|
+
* overhead (§2.4). Public so tests/callers can tune it. */
|
|
103
|
+
maxWaitMs = 20 * 60 * 1000;
|
|
104
|
+
/** OBSERVABILITY-ONLY watchdog ceiling — never force-releases. See the
|
|
105
|
+
* file-level doc comment and §4's "no forced release, ever" decision. */
|
|
106
|
+
maxHoldMs = 25 * 60 * 1000;
|
|
107
|
+
waitTickMs = 3000;
|
|
108
|
+
/** `applyUpstream` is the caller's binding to `CaddyProxy.setUpstream` for
|
|
109
|
+
* THIS session's Caddy instance, e.g. `(t) => caddy.setUpstream(t)`
|
|
110
|
+
* (§2.3's `createSessionTunnel`). The lock never imports/constructs a
|
|
111
|
+
* `CaddyProxy` itself — it only knows how to serialize calls to one. */
|
|
112
|
+
constructor(applyUpstream) {
|
|
113
|
+
this.applyUpstream = applyUpstream;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Acquire the shared route for `target`. Resolves once Caddy is CONFIRMED
|
|
117
|
+
* pointed at `target` (the repoint's PATCH has actually completed) —
|
|
118
|
+
* never before. Same-target callers join for free (zero additional
|
|
119
|
+
* admin-API calls, zero added wait once that generation's repoint has
|
|
120
|
+
* already resolved). Different-target callers block here — not just
|
|
121
|
+
* during the repoint — until the current holder(s) release.
|
|
122
|
+
*/
|
|
123
|
+
async acquire(target, opts) {
|
|
124
|
+
if (opts.signal?.aborted) {
|
|
125
|
+
throw new PortLockAbortedError(opts.callId, target.port);
|
|
126
|
+
}
|
|
127
|
+
// Synchronous decision, no `await` before the claim starts — this is
|
|
128
|
+
// what keeps the cross-tunnel dedup/joining logic race-free (§2.4).
|
|
129
|
+
if (this.gen === null || sameTarget(this.gen.target, target)) {
|
|
130
|
+
return this.claim(target, opts.callId);
|
|
131
|
+
}
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const entry = {
|
|
134
|
+
target,
|
|
135
|
+
callId: opts.callId,
|
|
136
|
+
enqueuedAt: Date.now(),
|
|
137
|
+
resolve,
|
|
138
|
+
reject,
|
|
139
|
+
onWaitProgress: opts.onWaitProgress,
|
|
140
|
+
signal: opts.signal,
|
|
141
|
+
};
|
|
142
|
+
this.queue.push(entry);
|
|
143
|
+
entry.timeoutTimer = setTimeout(() => {
|
|
144
|
+
const blockingPort = this.gen?.target.port;
|
|
145
|
+
const waitedMs = Date.now() - entry.enqueuedAt;
|
|
146
|
+
this.removeFromQueue(entry);
|
|
147
|
+
reject(new PortRouteQueueTimeoutError(target.port, blockingPort, waitedMs));
|
|
148
|
+
}, this.maxWaitMs);
|
|
149
|
+
entry.onAbort = () => {
|
|
150
|
+
const removed = this.removeFromQueue(entry);
|
|
151
|
+
// Only reject if this entry was still actually queued — a call that
|
|
152
|
+
// was already promoted has had its abort listener unregistered by
|
|
153
|
+
// promoteNextWaiter (see clearWaitTimers there), so onAbort firing
|
|
154
|
+
// after promotion should be unreachable; this guard just makes that
|
|
155
|
+
// unreachability failure-safe instead of a double-settle.
|
|
156
|
+
if (removed)
|
|
157
|
+
reject(new PortLockAbortedError(entry.callId, target.port));
|
|
158
|
+
};
|
|
159
|
+
opts.signal?.addEventListener('abort', entry.onAbort, { once: true });
|
|
160
|
+
if (opts.onWaitProgress)
|
|
161
|
+
this.startTicker(entry);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Claims a slot on the current generation, creating one if needed. Every
|
|
166
|
+
* code path through here — new generation or joining an existing one —
|
|
167
|
+
* ends in `await gen.readyPromise` before returning a handle. This is the
|
|
168
|
+
* §2.4 race fix: a joiner's `refCount`/`holders` reservation happens
|
|
169
|
+
* synchronously (before any `await`), so a same-target racer arriving
|
|
170
|
+
* between generation-creation and the first `await` still joins the one
|
|
171
|
+
* in-flight `applyUpstream` call instead of triggering a second one — but
|
|
172
|
+
* it still has to wait for that exact call to settle before it gets a
|
|
173
|
+
* handle back.
|
|
174
|
+
*/
|
|
175
|
+
async claim(target, callId) {
|
|
176
|
+
let gen = this.gen;
|
|
177
|
+
if (gen === null) {
|
|
178
|
+
const readyPromise = this.applyUpstream(target); // fired NOW, not awaited yet
|
|
179
|
+
gen = { target, refCount: 0, holders: new Set(), claimedAt: Date.now(), readyPromise };
|
|
180
|
+
this.gen = gen; // <-- visible to same-tick joiners before we suspend below
|
|
181
|
+
// Real per-claimer handling happens in each claimer's own catch block
|
|
182
|
+
// below; this just prevents an unhandled-rejection warning on the
|
|
183
|
+
// shared promise itself.
|
|
184
|
+
gen.readyPromise.catch(() => { });
|
|
185
|
+
this.armWatchdog(gen);
|
|
186
|
+
}
|
|
187
|
+
// Reserve BEFORE awaiting: a joiner that calls claim() while
|
|
188
|
+
// gen.readyPromise is still pending sees `this.gen` already set (from
|
|
189
|
+
// the branch above) and skips straight to this reservation + the SAME
|
|
190
|
+
// await — never a second PATCH/setUpstream call.
|
|
191
|
+
gen.refCount++;
|
|
192
|
+
gen.holders.add(callId);
|
|
193
|
+
try {
|
|
194
|
+
await gen.readyPromise; // every claimer — first AND joiners — wait HERE
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
gen.holders.delete(callId);
|
|
198
|
+
gen.refCount--;
|
|
199
|
+
if (gen.refCount === 0 && this.gen === gen) {
|
|
200
|
+
this.clearWatchdog(gen);
|
|
201
|
+
this.gen = null;
|
|
202
|
+
this.promoteNextWaiter();
|
|
203
|
+
}
|
|
204
|
+
throw new CaddyRepointError(target.port, err);
|
|
205
|
+
}
|
|
206
|
+
return { port: target.port, callId, release: () => this.release(target, callId) };
|
|
207
|
+
}
|
|
208
|
+
release(target, callId) {
|
|
209
|
+
if (!this.gen || !sameTarget(this.gen.target, target) || !this.gen.holders.delete(callId)) {
|
|
210
|
+
logger.warn(`portLock.release ignored — no matching holder (port=${target.port} callId=${callId})`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (--this.gen.refCount > 0)
|
|
214
|
+
return;
|
|
215
|
+
const finishedGen = this.gen;
|
|
216
|
+
this.clearWatchdog(finishedGen);
|
|
217
|
+
this.gen = null;
|
|
218
|
+
this.promoteNextWaiter();
|
|
219
|
+
}
|
|
220
|
+
promoteNextWaiter() {
|
|
221
|
+
if (this.queue.length === 0)
|
|
222
|
+
return;
|
|
223
|
+
const front = this.queue.shift();
|
|
224
|
+
this.clearWaitTimers(front);
|
|
225
|
+
// claim() runs synchronously up to its own `await gen.readyPromise` —
|
|
226
|
+
// by the time this call returns control here, `this.gen` already
|
|
227
|
+
// points at the NEW generation object with its readyPromise assigned,
|
|
228
|
+
// so every same-target waiter filtered below joins that exact promise,
|
|
229
|
+
// not a fresh one.
|
|
230
|
+
const promoted = this.claim(front.target, front.callId);
|
|
231
|
+
promoted.then(front.resolve, front.reject);
|
|
232
|
+
this.queue = this.queue.filter((e) => {
|
|
233
|
+
if (!sameTarget(e.target, front.target))
|
|
234
|
+
return true;
|
|
235
|
+
this.clearWaitTimers(e);
|
|
236
|
+
const joined = this.claim(e.target, e.callId); // joins front's readyPromise — zero new PATCHes
|
|
237
|
+
joined.then(e.resolve, e.reject); // resolves/rejects in lockstep with front
|
|
238
|
+
return false;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// ── Watchdog (§4: observability-only, never force-releases) ──────────────
|
|
242
|
+
armWatchdog(gen) {
|
|
243
|
+
gen.watchdogTimer = setTimeout(() => {
|
|
244
|
+
// The generation may already be gone (released/replaced) by the time
|
|
245
|
+
// this fires — nothing to report in that case.
|
|
246
|
+
if (this.gen !== gen)
|
|
247
|
+
return;
|
|
248
|
+
const heldMs = Date.now() - gen.claimedAt;
|
|
249
|
+
const holders = [...gen.holders];
|
|
250
|
+
logger.error(`portLock: shared route to port ${gen.target.port} has been held continuously for ` +
|
|
251
|
+
`${heldMs}ms, past maxHoldMs=${this.maxHoldMs}ms. Holders: ${holders.join(', ') || '(none)'}. ` +
|
|
252
|
+
`This is an OBSERVABILITY-ONLY watchdog — the lock is NOT being force-released ` +
|
|
253
|
+
`(forced release would risk misdirecting a still-live call to the wrong local port).`);
|
|
254
|
+
Telemetry.capture(TelemetryEvents.PORT_LOCK_MAX_HOLD_EXCEEDED, {
|
|
255
|
+
port: gen.target.port,
|
|
256
|
+
isHttpsLocal: Boolean(gen.target.isHttpsLocal),
|
|
257
|
+
heldMs,
|
|
258
|
+
maxHoldMs: this.maxHoldMs,
|
|
259
|
+
holders,
|
|
260
|
+
});
|
|
261
|
+
}, this.maxHoldMs);
|
|
262
|
+
// Never let this timer keep the process alive on its own.
|
|
263
|
+
gen.watchdogTimer.unref?.();
|
|
264
|
+
}
|
|
265
|
+
clearWatchdog(gen) {
|
|
266
|
+
if (gen.watchdogTimer)
|
|
267
|
+
clearTimeout(gen.watchdogTimer);
|
|
268
|
+
}
|
|
269
|
+
// ── Queue bookkeeping ──────────────────────────────────────────────────────
|
|
270
|
+
startTicker(entry) {
|
|
271
|
+
entry.tickTimer = setInterval(() => {
|
|
272
|
+
if (!entry.onWaitProgress)
|
|
273
|
+
return;
|
|
274
|
+
const blockingPort = this.gen?.target.port;
|
|
275
|
+
if (blockingPort === undefined)
|
|
276
|
+
return; // shouldn't happen while genuinely queued
|
|
277
|
+
const info = {
|
|
278
|
+
targetPort: entry.target.port,
|
|
279
|
+
blockingPort,
|
|
280
|
+
waitedMs: Date.now() - entry.enqueuedAt,
|
|
281
|
+
};
|
|
282
|
+
void Promise.resolve(entry.onWaitProgress(info)).catch((err) => {
|
|
283
|
+
logger.warn(`portLock: onWaitProgress callback threw for port ${entry.target.port}: ${err}`);
|
|
284
|
+
});
|
|
285
|
+
}, this.waitTickMs);
|
|
286
|
+
entry.tickTimer.unref?.();
|
|
287
|
+
}
|
|
288
|
+
/** Clears an entry's timeout/ticker timers and unregisters its abort
|
|
289
|
+
* listener. Called both when a queued entry is promoted (§5.4's
|
|
290
|
+
* dedicated test covers this) and when it's removed for any other
|
|
291
|
+
* reason (timeout, abort, cross-tunnel eviction). */
|
|
292
|
+
clearWaitTimers(entry) {
|
|
293
|
+
if (entry.timeoutTimer)
|
|
294
|
+
clearTimeout(entry.timeoutTimer);
|
|
295
|
+
if (entry.tickTimer)
|
|
296
|
+
clearInterval(entry.tickTimer);
|
|
297
|
+
if (entry.onAbort && entry.signal)
|
|
298
|
+
entry.signal.removeEventListener('abort', entry.onAbort);
|
|
299
|
+
}
|
|
300
|
+
/** Removes `entry` from the queue if still present, clearing its timers.
|
|
301
|
+
* Returns whether it was actually found/removed (a call may race a
|
|
302
|
+
* timeout/abort against being promoted). */
|
|
303
|
+
removeFromQueue(entry) {
|
|
304
|
+
const idx = this.queue.indexOf(entry);
|
|
305
|
+
if (idx === -1)
|
|
306
|
+
return false;
|
|
307
|
+
this.queue.splice(idx, 1);
|
|
308
|
+
this.clearWaitTimers(entry);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
// ── Test/diagnostic helpers ────────────────────────────────────────────────
|
|
312
|
+
/** Number of callers currently queued behind a different-port holder. */
|
|
313
|
+
get queueLength() {
|
|
314
|
+
return this.queue.length;
|
|
315
|
+
}
|
|
316
|
+
/** The port/isHttpsLocal the lock is currently holding a generation for,
|
|
317
|
+
* or null if nothing is currently claimed. */
|
|
318
|
+
get currentTarget() {
|
|
319
|
+
return this.gen ? this.gen.target : null;
|
|
320
|
+
}
|
|
321
|
+
}
|