@debugg-ai/debugg-ai-mcp 3.7.1 → 3.7.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.
|
@@ -14,7 +14,7 @@ import { TunnelProvisionError } from '../services/tunnels.js';
|
|
|
14
14
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
15
15
|
import { detectRepoName } from '../utils/gitContext.js';
|
|
16
16
|
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
17
|
-
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
17
|
+
import { probeLocalPort, probeTunnelHealth, extractNgrokErrorCode } from '../utils/localReachability.js';
|
|
18
18
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
19
19
|
import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
|
|
20
20
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
@@ -33,6 +33,28 @@ function getMaxTransientRetries() {
|
|
|
33
33
|
return 1;
|
|
34
34
|
return Math.min(n, 3);
|
|
35
35
|
}
|
|
36
|
+
// Bug z15n: scan run evidence for ngrok's interstitial marker. The marker is a
|
|
37
|
+
// stable, recognizable string the REMOTE BROWSER saw — positive evidence that it
|
|
38
|
+
// hit our tunnel's error page rather than the user's app. Non-string parts are
|
|
39
|
+
// serialized (the action trace is the usual carrier); a part we can't serialize
|
|
40
|
+
// is simply skipped — it is not evidence either way.
|
|
41
|
+
function findNgrokErrorMarker(parts) {
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
if (part === undefined || part === null || part === '')
|
|
44
|
+
continue;
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = typeof part === 'string' ? part : JSON.stringify(part) ?? '';
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const code = extractNgrokErrorCode(text);
|
|
53
|
+
if (code)
|
|
54
|
+
return code;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
36
58
|
// Concurrency control — max 2 simultaneous browser checks.
|
|
37
59
|
// Additional requests queue and run when a slot opens.
|
|
38
60
|
const MAX_CONCURRENT = 2;
|
|
@@ -99,10 +121,45 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
99
121
|
// dropped client kept polling for up to ~10 min, holding one of just
|
|
100
122
|
// MAX_CONCURRENT=2 slots. Wiring to context.signal cancels the poll and frees
|
|
101
123
|
// the slot immediately.
|
|
124
|
+
//
|
|
125
|
+
// Bead 5er7: aborting the poll frees our slot but does NOT stop the BACKEND
|
|
126
|
+
// execution — it runs on to its own contextData.timeoutSeconds (720), driving
|
|
127
|
+
// a real browser session and burning quota with nobody reading the result.
|
|
128
|
+
// cancelExecution() existed for exactly this and had zero callers. Cancel the
|
|
129
|
+
// in-flight execution best-effort on abort.
|
|
130
|
+
//
|
|
131
|
+
// Contract for the cancel path: NEVER throw and NEVER await. The client is
|
|
132
|
+
// already gone, so a failed cancel is not worth surfacing, and the abort path
|
|
133
|
+
// must not delay slot release. Strictly fire-and-forget, rejection swallowed.
|
|
134
|
+
let clientAborted = false;
|
|
135
|
+
let currentExecutionUuid = '';
|
|
136
|
+
const cancelCurrentExecution = () => {
|
|
137
|
+
// HELD OFF BY DEFAULT (bead 5er7 / sentinal-wmzdf). Cancelling the backend
|
|
138
|
+
// execution skips its browser teardown, so the BrowserSession row leaks
|
|
139
|
+
// ACTIVE and permanently burns one of the company's 50 concurrency slots —
|
|
140
|
+
// a net-negative trade vs. the pre-fix behaviour (poll aborts, backend runs
|
|
141
|
+
// to its own 720s budget, then teardown runs and the slot is returned:
|
|
142
|
+
// bounded and self-healing). Read at call time so the flag can be flipped
|
|
143
|
+
// to 'true' the moment the backend runs teardown on cancel (sentinal-wmzdf).
|
|
144
|
+
if (process.env.DEBUGGAI_CANCEL_ABANDONED_EXECUTIONS !== 'true')
|
|
145
|
+
return;
|
|
146
|
+
const uuid = currentExecutionUuid;
|
|
147
|
+
if (!uuid)
|
|
148
|
+
return; // nothing queued yet — never POST cancel/<empty>/
|
|
149
|
+
currentExecutionUuid = ''; // cancel any given execution at most once
|
|
150
|
+
try {
|
|
151
|
+
client.workflows?.cancelExecution(uuid).then(() => logger.info(`Cancelled abandoned execution ${uuid}`), (err) => logger.warn(`Best-effort cancel of abandoned execution ${uuid} failed: ${err}`));
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
logger.warn(`Best-effort cancel of abandoned execution ${uuid} threw synchronously: ${err}`);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
102
157
|
const abortController = new AbortController();
|
|
103
158
|
const onAbort = () => {
|
|
159
|
+
clientAborted = true;
|
|
104
160
|
abortController.abort();
|
|
105
161
|
progressDisabled = true; // client is gone — stop emitting
|
|
162
|
+
cancelCurrentExecution();
|
|
106
163
|
};
|
|
107
164
|
const requestSignal = context.signal;
|
|
108
165
|
if (requestSignal) {
|
|
@@ -336,7 +393,17 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
336
393
|
}
|
|
337
394
|
executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
|
|
338
395
|
executionUuid = executeResponse.executionUuid;
|
|
396
|
+
// Bead 5er7: this is now the execution an abort must cancel (a retry moves
|
|
397
|
+
// the target forward; the previous attempt already reached a terminal
|
|
398
|
+
// state, so there is nothing to cancel there).
|
|
399
|
+
currentExecutionUuid = executionUuid;
|
|
339
400
|
logger.info(`Execution queued: ${executionUuid}${attempt > 1 ? ` (retry ${attempt - 1}/${MAX_RETRIES})` : ''}`);
|
|
401
|
+
// The abort can fire BEFORE anything is queued — while provisioning the
|
|
402
|
+
// tunnel or resolving the template — and there is no abort check between
|
|
403
|
+
// there and here. Without this, a client that dropped during setup still
|
|
404
|
+
// gets a ~12-minute browser run queued on its behalf and abandoned.
|
|
405
|
+
if (clientAborted)
|
|
406
|
+
cancelCurrentExecution();
|
|
340
407
|
// Closure state — reset PER ATTEMPT so progress numbers don't double-count
|
|
341
408
|
// across retries.
|
|
342
409
|
let lastStepsTaken = 0;
|
|
@@ -410,6 +477,13 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
410
477
|
}
|
|
411
478
|
await progressCallback({ progress: execProgress, total: TOTAL_STEPS, message });
|
|
412
479
|
}, abortController.signal);
|
|
480
|
+
// Bead 5er7: pollExecution returned, so the execution reached a terminal
|
|
481
|
+
// state — there is nothing left to cancel and a late abort (while we shape
|
|
482
|
+
// the response) must not POST a cancel for finished work. EXCEPTION: on a
|
|
483
|
+
// poll-deadline timeout the execution may still be running backend-side,
|
|
484
|
+
// so keep it cancellable.
|
|
485
|
+
if (!finalExecution.timedOut)
|
|
486
|
+
currentExecutionUuid = '';
|
|
413
487
|
// Decide retry vs exit: only retry on documented transient signatures
|
|
414
488
|
// AND while we still have budget. Otherwise break and surface whatever
|
|
415
489
|
// result the agent reached.
|
|
@@ -496,6 +570,68 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
496
570
|
// Evidence: prefer the backend's contract evidence.actionTrace; fall back to
|
|
497
571
|
// the legacy node-extracted trace while the backend contract deploys.
|
|
498
572
|
const relayActionTrace = verdict.actionTrace ?? actionTrace;
|
|
573
|
+
// --- Post-hoc tunnel reclassification (bugs z15n, 4bui) ---
|
|
574
|
+
// The pre-flight probe above proves the tunnel was alive when we handed it to
|
|
575
|
+
// the remote browser — it can still die mid-run. When it does, the browser
|
|
576
|
+
// lands on ngrok's ERR_NGROK_* interstitial and the backend, which only sees
|
|
577
|
+
// "the page didn't contain what I was asked about", returns a normal 'fail'
|
|
578
|
+
// whose reason blames the USER'S page for OUR dead tunnel (execution
|
|
579
|
+
// a8f07747: 217s, 7 steps, failureCategory 'fail').
|
|
580
|
+
//
|
|
581
|
+
// THE MARKER IS REQUIRED (bug 4bui). Reclassifying overrides the backend's
|
|
582
|
+
// verdict, so it demands positive evidence of the claim we are actually
|
|
583
|
+
// making: that the remote browser hit OUR error page DURING THE RUN. Only
|
|
584
|
+
// the ERR_NGROK_* marker recorded BY the run is evidence of that.
|
|
585
|
+
//
|
|
586
|
+
// The re-probe is NOT such evidence and can no longer trigger this on its
|
|
587
|
+
// own. It answers a different question — "is the tunnel healthy NOW?" —
|
|
588
|
+
// and we were using its answer to assert something about the run window.
|
|
589
|
+
// That laundered a genuine UI failure into an infrastructure excuse:
|
|
590
|
+
// execution 2aa14b0b completed 2.78s BEFORE its upstream was killed (tunnel
|
|
591
|
+
// alive for 100% of the run, no marker, an honest evidence-strictness
|
|
592
|
+
// verdict) and we still stamped it TunnelOfflineDuringRun. Worse, the probe
|
|
593
|
+
// independently returned a FALSE NETWORK_ERROR on healthy servers ~1 in 5
|
|
594
|
+
// runs (bug k6yq), so the false positive was reachable with nothing
|
|
595
|
+
// whatsoever wrong. (k6yq is now fixed: the cause was not the suspected DNS
|
|
596
|
+
// race but an HTTP/2 GOAWAY from the ngrok edge on a freshly created
|
|
597
|
+
// tunnel, which probeTunnelHealth now retries. The marker requirement
|
|
598
|
+
// stands on its own regardless — the probe never decides this.)
|
|
599
|
+
//
|
|
600
|
+
// Requiring the marker loses no coverage: a real mid-run death fires BOTH
|
|
601
|
+
// arms (live-confirmed — re-probe NETWORK_ERROR *and* marker ERR_NGROK_3200),
|
|
602
|
+
// so the marker alone still catches it. The probe is kept purely as
|
|
603
|
+
// CORROBORATION in `detail` — it tells the caller whether the tunnel is
|
|
604
|
+
// still down now or has since recovered. Per epic 56kd ("relay honestly,
|
|
605
|
+
// invent nothing"), asserting an infrastructure fault we did not observe
|
|
606
|
+
// during the run is the relay inventing a cause.
|
|
607
|
+
let tunnelFault;
|
|
608
|
+
if (verdict.outcome === 'fail' && ctx.isLocalhost && ctx.tunnelId && ctx.targetUrl) {
|
|
609
|
+
const marker = findNgrokErrorMarker([
|
|
610
|
+
verdict.reason,
|
|
611
|
+
finalExecution.state?.error,
|
|
612
|
+
finalExecution.errorMessage,
|
|
613
|
+
relayActionTrace,
|
|
614
|
+
]);
|
|
615
|
+
// probeTunnelHealth never throws by contract; guard anyway — a probe we
|
|
616
|
+
// couldn't run is NOT evidence of a fault. Corroboration only: its result
|
|
617
|
+
// never decides whether we reclassify, only what we report alongside it.
|
|
618
|
+
const probe = await probeTunnelHealth(ctx.targetUrl).catch(() => undefined);
|
|
619
|
+
if (marker) {
|
|
620
|
+
tunnelFault = { probe, ngrokErrorCode: marker };
|
|
621
|
+
logger.warn(`Reclassifying backend 'fail' as an infrastructure fault for ${executionUuid}: ` +
|
|
622
|
+
`the run recorded ${marker} (re-probe now: ` +
|
|
623
|
+
`${probe ? (probe.healthy ? 'healthy — tunnel has since recovered' : probe.code) : 'unavailable'})`);
|
|
624
|
+
}
|
|
625
|
+
else if (probe && !probe.healthy) {
|
|
626
|
+
// Deliberately NOT reclassifying. The tunnel looks unhealthy now, but
|
|
627
|
+
// the run recorded no ngrok interstitial, so we have no evidence the
|
|
628
|
+
// browser ever saw one — the tunnel most likely died after the run (or
|
|
629
|
+
// the probe flaked). Relay the backend's verdict and log the tension.
|
|
630
|
+
logger.info(`Post-run tunnel re-probe for ${executionUuid} was unhealthy (${probe.code}) but the run ` +
|
|
631
|
+
'recorded no ERR_NGROK_* marker, so the browser reached the app during the run. Relaying ' +
|
|
632
|
+
"the backend's verdict verbatim rather than blaming the tunnel.");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
499
635
|
const responsePayload = {
|
|
500
636
|
outcome: verdict.outcome,
|
|
501
637
|
success: verdict.success,
|
|
@@ -513,6 +649,44 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
513
649
|
responsePayload.failureCategory = verdict.failureCategory;
|
|
514
650
|
if (verdict.reason)
|
|
515
651
|
responsePayload.reason = verdict.reason;
|
|
652
|
+
// Bug z15n: OUR tunnel died mid-run, so this 'fail' describes our error page,
|
|
653
|
+
// not the user's app. Relay it as a distinct, retryable infrastructure class
|
|
654
|
+
// so an automated caller doesn't record a UI regression that never happened.
|
|
655
|
+
// The backend's original verdict is preserved verbatim, never swallowed.
|
|
656
|
+
//
|
|
657
|
+
// Bug 4bui: the stated cause leads with the MARKER, because the marker is the
|
|
658
|
+
// evidence that justified getting here — the run itself recorded ngrok's
|
|
659
|
+
// interstitial. The probe only corroborates (and may legitimately say the
|
|
660
|
+
// tunnel has recovered by now), so it rides in `detail`, never as the cause.
|
|
661
|
+
if (tunnelFault) {
|
|
662
|
+
const { probe, ngrokErrorCode } = tunnelFault;
|
|
663
|
+
responsePayload.backendVerdict = { outcome: verdict.outcome, reason: verdict.reason };
|
|
664
|
+
responsePayload.outcome = 'error';
|
|
665
|
+
responsePayload.success = false;
|
|
666
|
+
responsePayload.failureCategory = 'infrastructure';
|
|
667
|
+
responsePayload.error = 'TunnelOfflineDuringRun';
|
|
668
|
+
responsePayload.message =
|
|
669
|
+
'During this run the remote browser landed on our tunnel\'s ngrok error page instead of your ' +
|
|
670
|
+
'app, so the check evaluated our error page rather than your UI. This is an infrastructure ' +
|
|
671
|
+
'fault on our side, not a failed check — retry it. The backend\'s original verdict is ' +
|
|
672
|
+
'preserved under `backendVerdict`.';
|
|
673
|
+
responsePayload.reason =
|
|
674
|
+
`The run recorded ngrok's ${ngrokErrorCode} interstitial, so the remote browser reached ` +
|
|
675
|
+
'our tunnel error page instead of your app.' +
|
|
676
|
+
(probe
|
|
677
|
+
? probe.healthy
|
|
678
|
+
? ' (Our re-probe after the run found the tunnel reachable again, so it has since recovered.)'
|
|
679
|
+
: ` (Our re-probe after the run also failed: ${probe.code}` +
|
|
680
|
+
`${probe.status ? ` (HTTP ${probe.status})` : ''}.)`
|
|
681
|
+
: '');
|
|
682
|
+
responsePayload.detail = {
|
|
683
|
+
ngrokErrorCode,
|
|
684
|
+
probeCode: probe?.code,
|
|
685
|
+
probeStatus: probe?.status,
|
|
686
|
+
probeHealthy: probe?.healthy,
|
|
687
|
+
probeElapsedMs: probe?.elapsedMs,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
516
690
|
if (Array.isArray(relayActionTrace) && relayActionTrace.length > 0)
|
|
517
691
|
responsePayload.actionTrace = relayActionTrace;
|
|
518
692
|
if (evaluation)
|
|
@@ -626,7 +800,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
626
800
|
content.push(link);
|
|
627
801
|
}
|
|
628
802
|
}
|
|
629
|
-
|
|
803
|
+
// Bug z15n: an infrastructure fault is an error, not a check result — same
|
|
804
|
+
// posture as the LocalServerUnreachable / TunnelTrafficBlocked pre-checks.
|
|
805
|
+
// The evidence (screenshot, trace, artifacts) still rides along in `content`.
|
|
806
|
+
return tunnelFault ? { content, isError: true } : { content };
|
|
630
807
|
}
|
|
631
808
|
catch (error) {
|
|
632
809
|
const duration = Date.now() - startTime;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ngrok agent session bootstrap (bead pqgj).
|
|
3
|
+
*
|
|
4
|
+
* ngrok's connect() calls its internal getProcess() and then IMMEDIATELY asks
|
|
5
|
+
* the agent to open a tunnel. But getProcess() resolves as soon as the agent
|
|
6
|
+
* prints its local API address, which — measured live against agent v3.38.0 —
|
|
7
|
+
* is ~293ms BEFORE the client session is established:
|
|
8
|
+
*
|
|
9
|
+
* [ 110ms] msg="starting web service" addr=127.0.0.1:4043 <- getProcess resolves
|
|
10
|
+
* [ 403ms] msg="client session established" <- ~293ms later
|
|
11
|
+
*
|
|
12
|
+
* Tunnelling inside that window returns 503 "a successful ngrok tunnel session
|
|
13
|
+
* has not yet been established". That 503 is retriable and ngrok retries it
|
|
14
|
+
* internally — but connectRetry() pins opts.name on the first call and reuses
|
|
15
|
+
* it, so its own retry collides with the tunnel record the 503 left behind:
|
|
16
|
+
* 400 "invalid tunnel configuration — tunnel already exists". 400 is not
|
|
17
|
+
* retriable, so it surfaces to us. That is bead pqgj's 100% attempt-1 failure.
|
|
18
|
+
*
|
|
19
|
+
* Fix: start the agent and wait for its session BEFORE anyone tunnels.
|
|
20
|
+
* getProcess() memoizes on a module-level promise, so ngrok's own connect()
|
|
21
|
+
* then reuses the very agent we started and never re-spawns it.
|
|
22
|
+
*
|
|
23
|
+
* getProcess is not in ngrok's package export map ({".": "./index.js"}), hence
|
|
24
|
+
* the resolved deep require. It is isolated in this module so the reach into
|
|
25
|
+
* another package's internals is explicit, greppable, and mockable — and so a
|
|
26
|
+
* version bump that moves it fails HERE, loudly, instead of silently spawning
|
|
27
|
+
* something unexpected. Callers treat a throw as "no pre-warm available" and
|
|
28
|
+
* fall back to their connect retry ladder.
|
|
29
|
+
*/
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { dirname, join } from 'node:path';
|
|
32
|
+
/**
|
|
33
|
+
* Spawn (or reuse) the ngrok agent, wiring the session callbacks.
|
|
34
|
+
*
|
|
35
|
+
* Resolves once the agent's local API is up — NOT once the session is live.
|
|
36
|
+
* Callers await the session via `onStatusChange('connected')`.
|
|
37
|
+
*
|
|
38
|
+
* @throws if ngrok's internals cannot be reached (version bump, stubbed module).
|
|
39
|
+
*/
|
|
40
|
+
export const startAgentSession = async (opts) => {
|
|
41
|
+
const require = createRequire(import.meta.url);
|
|
42
|
+
const ngrokEntry = require.resolve('ngrok');
|
|
43
|
+
const processModulePath = join(dirname(ngrokEntry), 'src', 'process.js');
|
|
44
|
+
const { getProcess } = require(processModulePath);
|
|
45
|
+
if (typeof getProcess !== 'function') {
|
|
46
|
+
throw new Error(`ngrok getProcess() not found at ${processModulePath}`);
|
|
47
|
+
}
|
|
48
|
+
await getProcess(opts);
|
|
49
|
+
};
|
|
@@ -22,6 +22,7 @@ import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl } from '../../u
|
|
|
22
22
|
import { v4 as uuidv4 } from 'uuid';
|
|
23
23
|
import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
|
|
24
24
|
import { getDefaultRegistry, } from './tunnelRegistry.js';
|
|
25
|
+
import { startAgentSession } from './ngrokAgentSession.js';
|
|
25
26
|
let ngrokModule = null;
|
|
26
27
|
async function getNgrok() {
|
|
27
28
|
if (!ngrokModule) {
|
|
@@ -66,6 +67,23 @@ class TunnelManager {
|
|
|
66
67
|
* changing the public API or depending on jest fake timers.
|
|
67
68
|
*/
|
|
68
69
|
connectBackoffMs = [500, 1500];
|
|
70
|
+
/**
|
|
71
|
+
* Bead pqgj: how the ngrok agent gets started + how we learn its client
|
|
72
|
+
* session is live. Overridable so tests can drive a fake agent instead of
|
|
73
|
+
* spawning a real ngrok process.
|
|
74
|
+
*/
|
|
75
|
+
agentSessionStarter = startAgentSession;
|
|
76
|
+
/**
|
|
77
|
+
* Cap on waiting for "client session established". Measured live at ~293ms;
|
|
78
|
+
* this is a generous ceiling, not an expected wait. On expiry we tunnel
|
|
79
|
+
* anyway and let the retry ladder handle it — a slow session must not become
|
|
80
|
+
* a hang.
|
|
81
|
+
*/
|
|
82
|
+
agentSessionTimeoutMs = 5000;
|
|
83
|
+
/** Whether the ngrok agent's client session is established (bead pqgj). */
|
|
84
|
+
agentSessionReady = false;
|
|
85
|
+
/** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
|
|
86
|
+
agentSessionPromise = null;
|
|
69
87
|
constructor(reg = getDefaultRegistry()) {
|
|
70
88
|
this.reg = reg;
|
|
71
89
|
// Bead `mdp`: sweep stale entries on startup so the registry doesn't grow
|
|
@@ -349,6 +367,11 @@ class TunnelManager {
|
|
|
349
367
|
hostname: tunnelDomain,
|
|
350
368
|
authtoken: authToken,
|
|
351
369
|
};
|
|
370
|
+
// Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
|
|
371
|
+
// agent's ~293ms not-ready window (which poisons the tunnel name via
|
|
372
|
+
// ngrok's own name-reusing internal retry and surfaces as
|
|
373
|
+
// "invalid tunnel configuration").
|
|
374
|
+
await this.ensureAgentSession(authToken, trace);
|
|
352
375
|
let lastError;
|
|
353
376
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
354
377
|
trace.emit('connect.attempt.start', { attempt });
|
|
@@ -481,6 +504,61 @@ class TunnelManager {
|
|
|
481
504
|
}
|
|
482
505
|
}
|
|
483
506
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
507
|
+
/**
|
|
508
|
+
* Bead pqgj: make sure the ngrok agent's client session is established before
|
|
509
|
+
* we ask it for a tunnel, so attempt 1 lands on a ready agent instead of the
|
|
510
|
+
* ~293ms not-ready window that made every single run fail its first connect.
|
|
511
|
+
*
|
|
512
|
+
* Never throws: if the agent can't be pre-warmed (ngrok internals moved, slow
|
|
513
|
+
* session, dead token) we fall through and let connectWithRetry's ladder do
|
|
514
|
+
* what it did before this fix. The ladder stays a genuine safety net.
|
|
515
|
+
*/
|
|
516
|
+
async ensureAgentSession(authtoken, trace) {
|
|
517
|
+
if (this.agentSessionReady)
|
|
518
|
+
return;
|
|
519
|
+
if (!this.agentSessionPromise) {
|
|
520
|
+
this.agentSessionPromise = (async () => {
|
|
521
|
+
let markEstablished;
|
|
522
|
+
const established = new Promise((resolve) => { markEstablished = resolve; });
|
|
523
|
+
await this.agentSessionStarter({
|
|
524
|
+
authtoken,
|
|
525
|
+
onStatusChange: (status) => {
|
|
526
|
+
if (status === 'connected') {
|
|
527
|
+
this.agentSessionReady = true;
|
|
528
|
+
markEstablished();
|
|
529
|
+
}
|
|
530
|
+
else if (status === 'closed') {
|
|
531
|
+
this.agentSessionReady = false;
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
onTerminated: () => {
|
|
535
|
+
// Agent process died — next tunnel must re-warm.
|
|
536
|
+
this.agentSessionReady = false;
|
|
537
|
+
this.agentSessionPromise = null;
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
let capTimer;
|
|
541
|
+
const cap = new Promise((resolve) => {
|
|
542
|
+
capTimer = setTimeout(resolve, this.agentSessionTimeoutMs);
|
|
543
|
+
});
|
|
544
|
+
try {
|
|
545
|
+
await Promise.race([established, cap]);
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
if (capTimer)
|
|
549
|
+
clearTimeout(capTimer);
|
|
550
|
+
}
|
|
551
|
+
})().catch((err) => {
|
|
552
|
+
// Pre-warm unavailable — not fatal, the ladder covers it.
|
|
553
|
+
this.agentSessionPromise = null;
|
|
554
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
555
|
+
trace?.emit('agent.session.prewarm-failed', { message: msg.slice(0, 200) });
|
|
556
|
+
logger.debug(`ngrok agent pre-warm unavailable, relying on connect retry ladder: ${msg}`);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
await this.agentSessionPromise;
|
|
560
|
+
trace?.emit('agent.session.ready', { ready: this.agentSessionReady });
|
|
561
|
+
}
|
|
484
562
|
async ensureInitialized() {
|
|
485
563
|
if (!this.initialized) {
|
|
486
564
|
try {
|
|
@@ -52,10 +52,68 @@ export async function probeLocalPort(port, opts = {}) {
|
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Bead k6yq: connection-level failures that mean "the edge isn't ready yet",
|
|
57
|
+
* NOT "the tunnel is broken".
|
|
58
|
+
*
|
|
59
|
+
* Measured live on freshly created tunnels against a server serving 200: the
|
|
60
|
+
* ngrok edge sends an HTTP/2 GOAWAY with code 0 (NO_ERROR) — a graceful
|
|
61
|
+
* "reconnect on a fresh connection" signal — which undici raises as
|
|
62
|
+
* UND_ERR_SOCKET behind an opaque "fetch failed". DNS resolved in 1ms, and a
|
|
63
|
+
* retry 250ms later returned 200. Treating that as a fault reported a healthy
|
|
64
|
+
* dev server as unreachable roughly 1 run in 5-14.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately EXCLUDED (these resolve and answer — they are real faults):
|
|
67
|
+
* ECONNREFUSED / EHOSTUNREACH / ENETUNREACH, TLS errors, any HTTP response
|
|
68
|
+
* (incl. 502 + ERR_NGROK_*), and timeouts.
|
|
69
|
+
*/
|
|
70
|
+
const TRANSIENT_CAUSE_CODES = new Set([
|
|
71
|
+
'ENOTFOUND', // DNS: name not resolvable yet
|
|
72
|
+
'EAI_AGAIN', // DNS: temporary resolver failure
|
|
73
|
+
'ECONNRESET', // edge dropped the connection mid-handshake
|
|
74
|
+
'EPIPE',
|
|
75
|
+
'UND_ERR_SOCKET', // undici socket error — this is the GOAWAY case
|
|
76
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
77
|
+
]);
|
|
78
|
+
/** undici hides the real error behind `TypeError: fetch failed` — dig it out. */
|
|
79
|
+
function errorCodeOf(err) {
|
|
80
|
+
return err?.cause?.code ?? err?.code;
|
|
81
|
+
}
|
|
82
|
+
function isTransientConnectionError(err) {
|
|
83
|
+
const code = errorCodeOf(err);
|
|
84
|
+
return code !== undefined && TRANSIENT_CAUSE_CODES.has(code);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Probe that traffic actually flows through the tunnel.
|
|
88
|
+
*
|
|
89
|
+
* Bead k6yq: a freshly created ngrok tunnel can bounce the first connection
|
|
90
|
+
* (HTTP/2 GOAWAY, DNS not yet resolvable) while being perfectly healthy, so
|
|
91
|
+
* transient connection-level failures are retried with a short backoff. Every
|
|
92
|
+
* other outcome — any HTTP response, connection refused, timeout — is returned
|
|
93
|
+
* on the first attempt, so a real fault is never retried into a false pass.
|
|
94
|
+
*/
|
|
55
95
|
export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
96
|
+
const maxAttempts = Math.max(1, opts.maxAttempts ?? 3);
|
|
97
|
+
const backoff = opts.retryBackoffMs ?? [150, 350];
|
|
98
|
+
const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
let last;
|
|
101
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
102
|
+
const { result, retryable } = await probeOnce(tunnelUrl, opts, started);
|
|
103
|
+
if (!retryable)
|
|
104
|
+
return result;
|
|
105
|
+
last = result;
|
|
106
|
+
if (attempt < maxAttempts) {
|
|
107
|
+
await sleep(backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 150);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Ladder exhausted on a transient error — the edge never came up. Report it:
|
|
111
|
+
// "not ready" that never becomes ready IS unhealthy.
|
|
112
|
+
return last;
|
|
113
|
+
}
|
|
114
|
+
async function probeOnce(tunnelUrl, opts, started) {
|
|
56
115
|
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
57
116
|
const fetchImpl = opts.fetchFn ?? fetch;
|
|
58
|
-
const started = Date.now();
|
|
59
117
|
const controller = new AbortController();
|
|
60
118
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
61
119
|
try {
|
|
@@ -71,51 +129,77 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
71
129
|
// ngrok error pages are small; a full user app body is a waste.
|
|
72
130
|
const bodyText = await readCapped(res, 4096);
|
|
73
131
|
const ngrokErr = extractNgrokErrorCode(bodyText);
|
|
74
|
-
// 502/504 + ngrok error marker → ngrok couldn't reach our server
|
|
132
|
+
// 502/504 + ngrok error marker → ngrok couldn't reach our server.
|
|
133
|
+
// A RESPONSE is proof the edge is up: never retried, so bead 4bui's
|
|
134
|
+
// marker-driven reclassification keeps seeing the real verdict.
|
|
75
135
|
if (ngrokErr) {
|
|
76
136
|
return {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
137
|
+
retryable: false,
|
|
138
|
+
result: {
|
|
139
|
+
healthy: false,
|
|
140
|
+
status: res.status,
|
|
141
|
+
code: 'NGROK_ERROR',
|
|
142
|
+
ngrokErrorCode: ngrokErr,
|
|
143
|
+
detail: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
|
|
144
|
+
elapsedMs: Date.now() - started,
|
|
145
|
+
},
|
|
83
146
|
};
|
|
84
147
|
}
|
|
85
148
|
if (res.status === 502 || res.status === 504) {
|
|
86
149
|
return {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
150
|
+
retryable: false,
|
|
151
|
+
result: {
|
|
152
|
+
healthy: false,
|
|
153
|
+
status: res.status,
|
|
154
|
+
code: 'BAD_GATEWAY',
|
|
155
|
+
detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`,
|
|
156
|
+
elapsedMs: Date.now() - started,
|
|
157
|
+
},
|
|
92
158
|
};
|
|
93
159
|
}
|
|
94
160
|
// Any other response (incl. 4xx from user's app) means traffic reached
|
|
95
161
|
// the dev server — that's healthy from the TUNNEL's perspective. The
|
|
96
162
|
// user's 404 is a user concern, not a tunnel concern.
|
|
97
163
|
return {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
164
|
+
retryable: false,
|
|
165
|
+
result: {
|
|
166
|
+
healthy: true,
|
|
167
|
+
status: res.status,
|
|
168
|
+
elapsedMs: Date.now() - started,
|
|
169
|
+
},
|
|
101
170
|
};
|
|
102
171
|
}
|
|
103
172
|
catch (err) {
|
|
104
173
|
clearTimeout(timer);
|
|
105
174
|
const e = err;
|
|
106
175
|
if (e?.name === 'AbortError' || /abort|timeout/i.test(e?.message ?? '')) {
|
|
176
|
+
// Not retried: a hanging tunnel must not cost 3x the timeout budget.
|
|
107
177
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
178
|
+
retryable: false,
|
|
179
|
+
result: {
|
|
180
|
+
healthy: false,
|
|
181
|
+
code: 'TIMEOUT',
|
|
182
|
+
detail: `tunnel health probe timed out after ${timeoutMs}ms`,
|
|
183
|
+
elapsedMs: Date.now() - started,
|
|
184
|
+
},
|
|
112
185
|
};
|
|
113
186
|
}
|
|
187
|
+
// Surface the cause code: undici reports every network failure as the
|
|
188
|
+
// undiagnosable "fetch failed" and hides the truth in err.cause.
|
|
189
|
+
const causeCode = errorCodeOf(e);
|
|
190
|
+
const baseMsg = e?.message ?? String(err);
|
|
191
|
+
const causeMsg = e?.cause?.message;
|
|
192
|
+
const detail = causeCode
|
|
193
|
+
? `${baseMsg} (${causeCode}${causeMsg && causeMsg !== baseMsg ? `: ${causeMsg}` : ''})`
|
|
194
|
+
: baseMsg;
|
|
114
195
|
return {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
196
|
+
retryable: isTransientConnectionError(e),
|
|
197
|
+
result: {
|
|
198
|
+
healthy: false,
|
|
199
|
+
code: 'NETWORK_ERROR',
|
|
200
|
+
detail,
|
|
201
|
+
elapsedMs: Date.now() - started,
|
|
202
|
+
},
|
|
119
203
|
};
|
|
120
204
|
}
|
|
121
205
|
}
|