Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion src/proxy/proxy-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,13 +723,19 @@ 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) {
this.logger.info(`[ProxyManager] Init command acknowledged before attempt ${attempt + 1}`);
return;
}
const timeoutMs = delays[Math.min(attempt, delays.length - 1)];
attemptsMade = attempt + 1;

let sendFailed = false;
try {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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'
}`;

Expand Down
10 changes: 8 additions & 2 deletions tests/unit/proxy/proxy-manager.handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ describe('ProxyManager sendInitWithRetry', () => {
const initPromise = (manager as unknown as { sendInitWithRetry: (command: object) => Promise<void> }).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();
Expand Down Expand Up @@ -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);
});
});
4 changes: 3 additions & 1 deletion tests/unit/proxy/proxy-manager.start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading