diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f6d032..ddcaf602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`packageManager` pinned to pnpm@10.33.0** — a different pnpm major regenerated `pnpm-lock.yaml` incompatibly (one contributor diff silently dropped the entire security `overrides` block); the pin plus a CONTRIBUTING note make the wrong-pnpm case fail fast, and the workflows read the pin instead of duplicating a version (#478) ### Fixed +- **Proxy-init failure reports the attempts actually made** — a launch that fast-failed on attempt 2 (worker exited, retries pointless — #515's early break) still said "Failed to initialize proxy after 6 attempts"; the count is the first thing a reader uses to judge how long the launch spent trying, so the fast-fail path now says "after 2 attempts (proxy exited; further retries skipped)" while genuine exhaustion keeps the full count (#517) - **Ruby launches survive rdbg's dropped `initialize` response** — rdbg can process `initialize` (provably: it emits the `initialized` event 34ms later) yet never send the response, and the proxy awaited that response unconditionally — so the launch sat silent until the 30s init deadline killed it blaming adapter startup, even though an immediate retry always worked. Ruby launch initialization now races the response against the `initialized` event plus a 2s grace period: when the event wins, the launch proceeds with unknown capabilities (a late response still captures them) and a warning names what happened. Attach sessions and other adapters are unchanged (#492) - **The 30s proxy-init timeout now says what actually stalled instead of blaming adapter installation** — the old message ("the debug adapter failed to start or is not properly configured — check that it is installed") was written for the nothing-ever-connected case and was confidently wrong for every other stage, sending agents to verify healthy toolchains with nowhere to go next (#492's incident: adapter spawned, connected, and emitted events — one response frame was missing). The worker now reports init progress to the parent (process spawned + PID, DAP transport connected, which handshake request is in flight), and the timeout message reflects the reached stage — e.g. *connected to the debug adapter, but the "initialize" request never received a response; the adapter process is running (PID N); an adapter-side protocol stall, not a missing install* — with the structured facts (`initProgress`, `proxyLogPath`) included in the failed `start_debugging` result's `data`, where the agent can actually see them. The install hint survives only for the case it correctly describes (#493) - **An adopted thread anchor survives `list_threads` and follow-up inspection calls** — the anchor adopted by `get_stack_trace`'s frameless-thread fallback (or an explicit `get_stack_trace {threadId}`, #465) was silently reverted to the stale stopped-event thread by the functional-core state echo on the very next DAP response or event: `list_threads` clobbered it, and even the documented `threadId` recovery survived exactly one inspection call because that call's own response reverted it — so stack → threads → locals answered about the frameless thread and returned nothing. The echo no longer overwrites the live anchor (the imperative fast path is authoritative), and adoptions are mirrored into the functional-core snapshot so the two stores can't disagree again (#496) diff --git a/src/proxy/proxy-manager.ts b/src/proxy/proxy-manager.ts index 1f8a6436..21b002ab 100644 --- a/src/proxy/proxy-manager.ts +++ b/src/proxy/proxy-manager.ts @@ -723,6 +723,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager { const timer = setTimeout(() => settle(false), ms); }); + // Attempts actually made, for the failure message: a fast-fail on a dead + // worker must not report the full retry budget as spent (issue #517) + let attemptsMade = 0; + let failedFast = false; + try { for (let attempt = 0; attempt <= maxRetries; attempt++) { if (acked) { @@ -730,6 +735,7 @@ export class ProxyManager extends EventEmitter implements IProxyManager { return; } const timeoutMs = delays[Math.min(attempt, delays.length - 1)]; + attemptsMade = attempt + 1; let sendFailed = false; try { @@ -744,6 +750,7 @@ export class ProxyManager extends EventEmitter implements IProxyManager { // fail fast with the detailed exit message instead of burning the // remaining retries against a process that is gone (issue #512) if (this.lastExitDetails) { + failedFast = true; break; } } @@ -775,7 +782,13 @@ export class ProxyManager extends EventEmitter implements IProxyManager { this.removeListener('init-received', onAck); } - let detailMessage = `Failed to initialize proxy after ${maxRetries + 1} attempts. ${ + // Report the attempts actually made: "after 6 attempts" on a launch that + // fast-failed on attempt 2 misreads how long the launch spent trying + // (issue #517) + const attemptSummary = failedFast + ? `after ${attemptsMade} attempt${attemptsMade === 1 ? '' : 's'} (proxy exited; further retries skipped)` + : `after ${attemptsMade} attempts`; + let detailMessage = `Failed to initialize proxy ${attemptSummary}. ${ lastError ? `Last error: ${lastError.message}` : 'Init command not acknowledged' }`; diff --git a/tests/unit/proxy/proxy-manager.handshake.test.ts b/tests/unit/proxy/proxy-manager.handshake.test.ts index 8d488a30..b691ec3a 100644 --- a/tests/unit/proxy/proxy-manager.handshake.test.ts +++ b/tests/unit/proxy/proxy-manager.handshake.test.ts @@ -135,7 +135,11 @@ describe('ProxyManager sendInitWithRetry', () => { const initPromise = (manager as unknown as { sendInitWithRetry: (command: object) => Promise }).sendInitWithRetry( { cmd: 'init' } ); - const rejection = expect(initPromise).rejects.toThrow('Proxy exit details -> code=1'); + // The message reports the attempts actually made, flagged as a fast-fail + // (issue #517), and still carries the exit details + const rejection = expect(initPromise).rejects.toThrow( + /after 2 attempts \(proxy exited; further retries skipped\)[\s\S]*Proxy exit details -> code=1/ + ); vi.advanceTimersByTime(500); // attempt 1 window expires await Promise.resolve(); @@ -175,7 +179,9 @@ describe('ProxyManager sendInitWithRetry', () => { } } - await expect(initPromise).rejects.toThrow('Failed to initialize proxy'); + // Exhaustion reports the full count without the fast-fail suffix — every + // one of the 6 attempts was really made (issue #517) + await expect(initPromise).rejects.toThrow(/Failed to initialize proxy after 6 attempts\./); expect(sendCommandMock).toHaveBeenCalledTimes(6); }); }); diff --git a/tests/unit/proxy/proxy-manager.start.test.ts b/tests/unit/proxy/proxy-manager.start.test.ts index 181930c6..d6cd9128 100644 --- a/tests/unit/proxy/proxy-manager.start.test.ts +++ b/tests/unit/proxy/proxy-manager.start.test.ts @@ -512,7 +512,9 @@ describe('ProxyManager.start', () => { // from an exited worker) — the failure surfaces on attempt 2's failed // send instead of burning ~15s of retries (issue #512), and carries // the exit details. - const rejection = expect(startPromise).rejects.toThrow(/Failed to initialize proxy after 6 attempts\. Last error: Proxy process not available[\s\S]*code=12 signal=SIGTERM/); + // The message reports the attempts actually made — 2, not the full + // retry budget of 6 the loop never spent (issue #517) + const rejection = expect(startPromise).rejects.toThrow(/Failed to initialize proxy after 2 attempts \(proxy exited; further retries skipped\)\. Last error: Proxy process not available[\s\S]*code=12 signal=SIGTERM/); await vi.advanceTimersByTimeAsync(8000); await rejection;