fix(processes): fix multiple clawx processes running concurently (#589)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
This commit is contained in:
Haze
2026-03-20 18:34:20 +08:00
committed by GitHub
Unverified
parent 016ebb2b7b
commit 9b503b531b
15 changed files with 844 additions and 26 deletions

View File

@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const originalPlatform = process.platform;
const { mockExec } = vi.hoisted(() => ({
mockExec: vi.fn(),
}));
vi.mock('child_process', () => ({
exec: mockExec,
default: {
exec: mockExec,
},
}));
vi.mock('@electron/utils/agent-config', () => ({
assignChannelToAgent: vi.fn(),
clearChannelBinding: vi.fn(),
createAgent: vi.fn(),
deleteAgentConfig: vi.fn(),
listAgentsSnapshot: vi.fn(),
removeAgentWorkspaceDirectory: vi.fn(),
resolveAccountIdForAgent: vi.fn(),
updateAgentName: vi.fn(),
}));
vi.mock('@electron/utils/channel-config', () => ({
deleteChannelAccountConfig: vi.fn(),
}));
vi.mock('@electron/services/providers/provider-runtime-sync', () => ({
syncAllProviderAuthToRuntime: vi.fn(),
}));
vi.mock('@electron/api/route-utils', () => ({
parseJsonBody: vi.fn(),
sendJson: vi.fn(),
}));
function setPlatform(platform: string): void {
Object.defineProperty(process, 'platform', { value: platform, writable: true });
}
describe('restartGatewayForAgentDeletion', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.resetModules();
mockExec.mockImplementation((_cmd: string, _opts: object, cb: (err: Error | null, stdout: string) => void) => {
cb(null, '');
return {} as never;
});
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
});
it('uses taskkill tree strategy on Windows when gateway pid is known', async () => {
setPlatform('win32');
const { restartGatewayForAgentDeletion } = await import('@electron/api/routes/agents');
const restart = vi.fn().mockResolvedValue(undefined);
const getStatus = vi.fn(() => ({ pid: 4321, port: 18789 }));
await restartGatewayForAgentDeletion({
gatewayManager: {
getStatus,
restart,
},
} as never);
expect(mockExec).toHaveBeenCalledWith(
'taskkill /F /PID 4321 /T',
expect.any(Function),
);
expect(restart).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,137 @@
import { EventEmitter } from 'node:events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const originalPlatform = process.platform;
const {
mockExec,
mockCreateServer,
} = vi.hoisted(() => ({
mockExec: vi.fn(),
mockCreateServer: vi.fn(),
}));
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: () => '/tmp',
},
utilityProcess: {},
}));
vi.mock('child_process', () => ({
exec: mockExec,
execSync: vi.fn(),
spawn: vi.fn(),
default: {
exec: mockExec,
execSync: vi.fn(),
spawn: vi.fn(),
},
}));
vi.mock('net', () => ({
createServer: mockCreateServer,
}));
class MockUtilityChild extends EventEmitter {
pid?: number;
kill = vi.fn();
constructor(pid?: number) {
super();
this.pid = pid;
}
}
function setPlatform(platform: string): void {
Object.defineProperty(process, 'platform', { value: platform, writable: true });
}
describe('gateway supervisor process cleanup', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockExec.mockImplementation((_cmd: string, _opts: object, cb: (err: Error | null, stdout: string) => void) => {
cb(null, '');
return {} as never;
});
mockCreateServer.mockImplementation(() => {
const handlers = new Map<string, (...args: unknown[]) => void>();
return {
once(event: string, callback: (...args: unknown[]) => void) {
handlers.set(event, callback);
return this;
},
listen() {
queueMicrotask(() => handlers.get('listening')?.());
return this;
},
close(callback?: () => void) {
callback?.();
},
};
});
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
});
it('uses taskkill tree strategy for owned process on Windows', async () => {
setPlatform('win32');
const child = new MockUtilityChild(4321);
const { terminateOwnedGatewayProcess } = await import('@electron/gateway/supervisor');
const stopPromise = terminateOwnedGatewayProcess(child as unknown as Electron.UtilityProcess);
child.emit('exit', 0);
await stopPromise;
await vi.waitFor(() => {
expect(mockExec).toHaveBeenCalledWith(
'taskkill /F /PID 4321 /T',
expect.objectContaining({ timeout: 5000, windowsHide: true }),
expect.any(Function),
);
});
expect(child.kill).not.toHaveBeenCalled();
});
it('uses direct child.kill for owned process on non-Windows', async () => {
setPlatform('linux');
const child = new MockUtilityChild(9876);
const { terminateOwnedGatewayProcess } = await import('@electron/gateway/supervisor');
const stopPromise = terminateOwnedGatewayProcess(child as unknown as Electron.UtilityProcess);
child.emit('exit', 0);
await stopPromise;
expect(child.kill).toHaveBeenCalledTimes(1);
});
it('waits for port release after orphan cleanup on Windows', async () => {
setPlatform('win32');
const { findExistingGatewayProcess } = await import('@electron/gateway/supervisor');
mockExec.mockImplementation((cmd: string, _opts: object, cb: (err: Error | null, stdout: string) => void) => {
if (cmd.includes('netstat -ano')) {
cb(null, ' TCP 127.0.0.1:18789 0.0.0.0:0 LISTENING 4321\n');
return {} as never;
}
cb(null, '');
return {} as never;
});
const result = await findExistingGatewayProcess({ port: 18789 });
expect(result).toBeNull();
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining('taskkill /F /PID 4321 /T'),
expect.objectContaining({ timeout: 5000, windowsHide: true }),
expect.any(Function),
);
expect(mockCreateServer).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import {
createQuitLifecycleState,
markQuitCleanupCompleted,
requestQuitLifecycleAction,
} from '@electron/main/quit-lifecycle';
describe('main quit lifecycle coordination', () => {
it('starts cleanup only once', () => {
const state = createQuitLifecycleState();
expect(requestQuitLifecycleAction(state)).toBe('start-cleanup');
expect(requestQuitLifecycleAction(state)).toBe('cleanup-in-progress');
});
it('allows quit after cleanup is marked complete', () => {
const state = createQuitLifecycleState();
expect(requestQuitLifecycleAction(state)).toBe('start-cleanup');
markQuitCleanupCompleted(state);
expect(requestQuitLifecycleAction(state)).toBe('allow-quit');
});
});

View File

@@ -0,0 +1,157 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { acquireProcessInstanceFileLock } from '@electron/main/process-instance-lock';
const tempDirs: string[] = [];
function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'clawx-instance-lock-'));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) continue;
rmSync(dir, { recursive: true, force: true });
}
});
describe('process instance file lock', () => {
it('acquires lock and writes owner pid', () => {
const userDataDir = createTempDir();
const lock = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 12345,
});
const lockPath = join(userDataDir, 'clawx.instance.lock');
expect(lock.acquired).toBe(true);
expect(existsSync(lockPath)).toBe(true);
expect(readFileSync(lockPath, 'utf8')).toBe('12345');
lock.release();
expect(existsSync(lockPath)).toBe(false);
});
it('rejects a second lock when owner pid is alive', () => {
const userDataDir = createTempDir();
const first = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 2222,
isPidAlive: () => true,
});
const second = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 3333,
isPidAlive: () => true,
});
expect(first.acquired).toBe(true);
expect(second.acquired).toBe(false);
expect(second.ownerPid).toBe(2222);
expect(second.ownerFormat).toBe('legacy');
first.release();
});
it('replaces stale lock file when owner pid is not alive', () => {
const userDataDir = createTempDir();
const lockPath = join(userDataDir, 'clawx.instance.lock');
writeFileSync(lockPath, '4444', 'utf8');
const lock = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 5555,
isPidAlive: () => false,
});
expect(lock.acquired).toBe(true);
expect(readFileSync(lockPath, 'utf8')).toBe('5555');
lock.release();
});
it('replaces stale structured lock file when owner pid is not alive', () => {
const userDataDir = createTempDir();
const lockPath = join(userDataDir, 'clawx.instance.lock');
writeFileSync(lockPath, JSON.stringify({
schema: 'clawx-instance-lock',
version: 1,
pid: 7777,
}), 'utf8');
const lock = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 6666,
isPidAlive: () => false,
});
expect(lock.acquired).toBe(true);
expect(readFileSync(lockPath, 'utf8')).toBe('6666');
lock.release();
});
it('does not treat malformed lock file content as stale', () => {
const userDataDir = createTempDir();
const lockPath = join(userDataDir, 'clawx.instance.lock');
writeFileSync(lockPath, 'not-a-pid', 'utf8');
const lock = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 6666,
});
expect(lock.acquired).toBe(false);
expect(lock.ownerPid).toBeUndefined();
expect(lock.ownerFormat).toBe('unknown');
expect(readFileSync(lockPath, 'utf8')).toBe('not-a-pid');
});
it('does not remove lock file if ownership changed before release', () => {
const userDataDir = createTempDir();
const lockPath = join(userDataDir, 'clawx.instance.lock');
const first = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 1234,
});
// Simulate a new process acquiring the lock after a handover race.
writeFileSync(lockPath, '9999', 'utf8');
first.release();
expect(readFileSync(lockPath, 'utf8')).toBe('9999');
});
it('does not treat unknown structured lock schema as stale', () => {
const userDataDir = createTempDir();
const lockPath = join(userDataDir, 'clawx.instance.lock');
writeFileSync(lockPath, JSON.stringify({
schema: 'future-lock-schema',
version: 2,
pid: 8888,
owner: 'future-build',
}), 'utf8');
const lock = acquireProcessInstanceFileLock({
userDataDir,
lockName: 'clawx',
pid: 9999,
});
expect(lock.acquired).toBe(false);
expect(lock.ownerPid).toBeUndefined();
expect(lock.ownerFormat).toBe('unknown');
expect(readFileSync(lockPath, 'utf8')).toContain('future-lock-schema');
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { createSignalQuitHandler } from '@electron/main/signal-quit';
describe('signal quit handler', () => {
it('logs and requests quit when signal is received', () => {
const logInfo = vi.fn();
const requestQuit = vi.fn();
const handler = createSignalQuitHandler({ logInfo, requestQuit });
handler('SIGTERM');
expect(logInfo).toHaveBeenCalledWith('Received SIGTERM; requesting app quit');
expect(requestQuit).toHaveBeenCalledTimes(1);
});
});