diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9e64bbae..dff78461 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -205,6 +205,9 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_POLL_MS = 500; +const MCP_PREFLIGHT_SHUTDOWN_GRACE_MS = 250; +const MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 2_000; +const MCP_PREFLIGHT_SHUTDOWN_POLL_MS = 50; const STDERR_RING_LIMIT = 64 * 1024; const STDOUT_RING_LIMIT = 64 * 1024; // Progress emissions fan out the latest CLI tail + assistant output to the @@ -1004,6 +1007,39 @@ async function waitForPidsToExit( } } +async function waitForChildProcessToExit( + child: ChildProcess | null | undefined, + timeoutMs: number +): Promise { + if (!child?.pid || !isProcessAlive(child.pid)) { + return; + } + + await new Promise((resolve) => { + let settled = false; + let timeoutHandle: ReturnType | null = null; + + const finish = (): void => { + if (settled) { + return; + } + settled = true; + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + child.off('close', finish); + child.off('exit', finish); + child.off('error', finish); + resolve(); + }; + + timeoutHandle = setTimeout(finish, timeoutMs); + child.once('close', finish); + child.once('exit', finish); + child.once('error', finish); + }); +} + async function tryReadRegularFileUtf8( filePath: string, opts: { timeoutMs: number; maxBytes: number } @@ -13588,11 +13624,26 @@ export class TeamProvisioningService { throw new Error(this.buildAgentTeamsMcpValidationError(errorText)); } finally { rejectAll(new Error('agent-teams MCP preflight session closed')); - if (child?.stdin && !child.stdin.destroyed) { - child.stdin.end(); + if (child?.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { + const stdin = child.stdin; + await new Promise((resolve) => { + try { + stdin.end(() => resolve()); + } catch { + resolve(); + } + }); } - if (child) { - killProcessTree(child); + if (child?.pid) { + await waitForChildProcessToExit(child, MCP_PREFLIGHT_SHUTDOWN_GRACE_MS); + if (isProcessAlive(child.pid)) { + killProcessTree(child); + await waitForPidsToExit([child.pid], { + timeoutMs: MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS, + pollMs: MCP_PREFLIGHT_SHUTDOWN_POLL_MS, + }); + await waitForChildProcessToExit(child, MCP_PREFLIGHT_SHUTDOWN_GRACE_MS); + } } await fs.promises.rm(fixture.claudeDir, { recursive: true, force: true }).catch(() => {}); } diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index dfefce73..9f30a0a9 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -144,6 +144,26 @@ process.stdin.on('data', (chunk) => { return scriptPath; } +async function removeTempRoot(dirPath: string): Promise { + if (!dirPath) { + return; + } + + const maxAttempts = process.platform === 'win32' ? 20 : 1; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await fs.promises.rm(dirPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === maxAttempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} + describe('TeamProvisioningService prepare/auth behavior', () => { let tempRoot = ''; @@ -166,13 +186,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => { delete process.env.ANTHROPIC_AUTH_TOKEN; }); - afterEach(() => { - fs.rmSync(tempRoot, { - recursive: true, - force: true, - maxRetries: 5, - retryDelay: 200, - }); + afterEach(async () => { + await removeTempRoot(tempRoot); }); it('does not create missing directories during prepareForProvisioning', async () => {