Skip to content

Commit cd47e9b

Browse files
authored
Merge pull request #2666 from qqqys/feat/vscode-acp-reconnect-logic
feat(vscode): add retry logic and auto-reconnect for ACP connection
2 parents 449a421 + ca6cf82 commit cd47e9b

File tree

6 files changed

+465
-19
lines changed

6 files changed

+465
-19
lines changed

packages/vscode-ide-companion/src/services/acpConnection.test.ts

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, expect, it, vi } from 'vitest';
7+
import { describe, expect, it, vi, beforeEach } from 'vitest';
88
import { RequestError } from '@agentclientprotocol/sdk';
99
import type { ContentBlock } from '@agentclientprotocol/sdk';
1010

@@ -21,8 +21,11 @@ type AcpConnectionInternal = {
2121
sessionId: string | null;
2222
lastExitCode: number | null;
2323
lastExitSignal: string | null;
24+
intentionalDisconnect: boolean;
25+
autoReconnectAttempts: number;
2426
mapReadTextFileError: (error: unknown, filePath: string) => unknown;
2527
ensureConnection: () => unknown;
28+
cleanupForRetry: () => void;
2629
};
2730

2831
function createConnection(overrides?: Partial<AcpConnectionInternal>) {
@@ -224,3 +227,174 @@ describe('AcpConnection lastExitCode/lastExitSignal', () => {
224227
expect(conn.lastExitSignal).toBeNull();
225228
});
226229
});
230+
231+
describe('AcpConnection.connectWithRetry', () => {
232+
let acpConn: AcpConnection;
233+
234+
beforeEach(() => {
235+
acpConn = new AcpConnection();
236+
});
237+
238+
it('succeeds on first attempt without retrying', async () => {
239+
const connectSpy = vi
240+
.spyOn(acpConn, 'connect')
241+
.mockResolvedValueOnce(undefined);
242+
243+
await acpConn.connectWithRetry('/path/to/cli.js', '/workdir', [], 3);
244+
245+
expect(connectSpy).toHaveBeenCalledTimes(1);
246+
expect(connectSpy).toHaveBeenCalledWith('/path/to/cli.js', '/workdir', []);
247+
});
248+
249+
it('retries on failure and succeeds on second attempt', async () => {
250+
const connectSpy = vi
251+
.spyOn(acpConn, 'connect')
252+
.mockRejectedValueOnce(new Error('SIGTERM'))
253+
.mockResolvedValueOnce(undefined);
254+
255+
await acpConn.connectWithRetry('/path/to/cli.js', '/workdir', [], 3);
256+
257+
expect(connectSpy).toHaveBeenCalledTimes(2);
258+
});
259+
260+
it('throws after all retries are exhausted', async () => {
261+
const error = new Error('persistent failure');
262+
const connectSpy = vi.spyOn(acpConn, 'connect').mockRejectedValue(error);
263+
264+
await expect(
265+
acpConn.connectWithRetry('/path/to/cli.js', '/workdir', [], 2),
266+
).rejects.toThrow('persistent failure');
267+
268+
// 1 initial + 2 retries = 3 total
269+
expect(connectSpy).toHaveBeenCalledTimes(3);
270+
});
271+
272+
it('cleans up state between retry attempts', async () => {
273+
const internal = acpConn as unknown as AcpConnectionInternal;
274+
const cleanupSpy = vi.spyOn(internal, 'cleanupForRetry' as never);
275+
276+
vi.spyOn(acpConn, 'connect')
277+
.mockRejectedValueOnce(new Error('fail 1'))
278+
.mockResolvedValueOnce(undefined);
279+
280+
await acpConn.connectWithRetry('/path/to/cli.js', '/workdir', [], 3);
281+
282+
// cleanupForRetry called once for the failed attempt
283+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
284+
});
285+
286+
it('resets autoReconnectAttempts on successful connect', async () => {
287+
const internal = acpConn as unknown as AcpConnectionInternal;
288+
internal.autoReconnectAttempts = 5;
289+
290+
vi.spyOn(acpConn, 'connect').mockResolvedValueOnce(undefined);
291+
292+
await acpConn.connectWithRetry('/path/to/cli.js', '/workdir', [], 3);
293+
294+
expect(acpConn.currentAutoReconnectAttempts).toBe(0);
295+
});
296+
});
297+
298+
describe('AcpConnection.cleanupForRetry', () => {
299+
it('kills zombie child process and resets state', () => {
300+
const mockKill = vi.fn();
301+
const conn = createConnection({
302+
child: createMockChild({ kill: mockKill, killed: false }),
303+
sdkConnection: { fake: true },
304+
sessionId: 'test-session',
305+
lastExitCode: 1,
306+
lastExitSignal: 'SIGTERM',
307+
});
308+
309+
conn.cleanupForRetry();
310+
311+
expect(mockKill).toHaveBeenCalledOnce();
312+
expect(conn.child).toBeNull();
313+
expect(conn.sdkConnection).toBeNull();
314+
expect(conn.sessionId).toBeNull();
315+
expect(conn.lastExitCode).toBeNull();
316+
expect(conn.lastExitSignal).toBeNull();
317+
});
318+
319+
it('handles already-killed child process gracefully', () => {
320+
const conn = createConnection({
321+
child: createMockChild({ killed: true }),
322+
sdkConnection: { fake: true },
323+
sessionId: 'test',
324+
});
325+
326+
expect(() => conn.cleanupForRetry()).not.toThrow();
327+
expect(conn.child).toBeNull();
328+
});
329+
330+
it('handles null child process gracefully', () => {
331+
const conn = createConnection({
332+
child: null,
333+
sdkConnection: { fake: true },
334+
sessionId: 'test',
335+
});
336+
337+
expect(() => conn.cleanupForRetry()).not.toThrow();
338+
});
339+
});
340+
341+
describe('AcpConnection intentionalDisconnect flag', () => {
342+
it('defaults to false', () => {
343+
const acpConn = new AcpConnection();
344+
expect(acpConn.wasIntentionalDisconnect).toBe(false);
345+
});
346+
347+
it('is set to true by disconnect()', () => {
348+
const conn = createConnection({
349+
child: createMockChild(),
350+
sdkConnection: {},
351+
sessionId: 'test',
352+
});
353+
const acpConn = conn as unknown as AcpConnection;
354+
355+
acpConn.disconnect();
356+
357+
expect(acpConn.wasIntentionalDisconnect).toBe(true);
358+
});
359+
360+
it('is reset to false when connect() is called', async () => {
361+
const internal = new AcpConnection() as unknown as AcpConnectionInternal;
362+
internal.intentionalDisconnect = true;
363+
364+
// connect() will throw because we haven't set up a real subprocess,
365+
// but the flag should be reset before the error
366+
try {
367+
await (internal as unknown as AcpConnection).connect(
368+
'/nonexistent/cli.js',
369+
'/workdir',
370+
);
371+
} catch {
372+
// Expected to fail
373+
}
374+
375+
expect(internal.intentionalDisconnect).toBe(false);
376+
});
377+
});
378+
379+
describe('AcpConnection auto-reconnect counter', () => {
380+
it('defaults to 0', () => {
381+
const acpConn = new AcpConnection();
382+
expect(acpConn.currentAutoReconnectAttempts).toBe(0);
383+
});
384+
385+
it('increments via incrementAutoReconnectAttempts()', () => {
386+
const acpConn = new AcpConnection();
387+
acpConn.incrementAutoReconnectAttempts();
388+
expect(acpConn.currentAutoReconnectAttempts).toBe(1);
389+
acpConn.incrementAutoReconnectAttempts();
390+
expect(acpConn.currentAutoReconnectAttempts).toBe(2);
391+
});
392+
393+
it('resets via resetAutoReconnectAttempts()', () => {
394+
const acpConn = new AcpConnection();
395+
acpConn.incrementAutoReconnectAttempts();
396+
acpConn.incrementAutoReconnectAttempts();
397+
acpConn.resetAutoReconnectAttempts();
398+
expect(acpConn.currentAutoReconnectAttempts).toBe(0);
399+
});
400+
});

0 commit comments

Comments
 (0)