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:
78
tests/unit/agents-routes.test.ts
Normal file
78
tests/unit/agents-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
137
tests/unit/gateway-supervisor.test.ts
Normal file
137
tests/unit/gateway-supervisor.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
tests/unit/main-quit-lifecycle.test.ts
Normal file
23
tests/unit/main-quit-lifecycle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
157
tests/unit/process-instance-lock.test.ts
Normal file
157
tests/unit/process-instance-lock.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
15
tests/unit/signal-quit.test.ts
Normal file
15
tests/unit/signal-quit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user