@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.
@@ -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
+ }