a2acalling 0.6.46 → 0.6.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +57 -10
- package/docs/plans/2026-02-16-orphan-process-fix.md +962 -0
- package/package.json +1 -1
- package/src/lib/pid-file.js +103 -0
- package/src/server.js +12 -0
package/bin/cli.js
CHANGED
|
@@ -1635,7 +1635,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1635
1635
|
if (statusData.agent.owner_name) console.log(` Owner: ${statusData.agent.owner_name}`);
|
|
1636
1636
|
}
|
|
1637
1637
|
if (statusData.invite_host) {
|
|
1638
|
-
|
|
1638
|
+
const ih = typeof statusData.invite_host === 'object' ? statusData.invite_host.host : statusData.invite_host;
|
|
1639
|
+
if (ih) console.log(` Invite host: ${ih}`);
|
|
1639
1640
|
}
|
|
1640
1641
|
if (statusData.warnings && statusData.warnings.length) {
|
|
1641
1642
|
console.log('');
|
|
@@ -1953,6 +1954,19 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1953
1954
|
return;
|
|
1954
1955
|
}
|
|
1955
1956
|
|
|
1957
|
+
// Pre-start cleanup: kill any existing a2a server from a previous run
|
|
1958
|
+
try {
|
|
1959
|
+
const { killExistingServer } = require('../src/lib/pid-file');
|
|
1960
|
+
const cleanup = killExistingServer();
|
|
1961
|
+
if (cleanup.killed) {
|
|
1962
|
+
console.log(` Stopped previous server (PID ${cleanup.pid}).`);
|
|
1963
|
+
// Brief pause to let the port free up
|
|
1964
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1965
|
+
}
|
|
1966
|
+
} catch (e) {
|
|
1967
|
+
// Best effort — continue with startup
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1956
1970
|
const isAlreadyListening = await isPortListening(serverPort, '127.0.0.1', { timeoutMs: 250 });
|
|
1957
1971
|
let serverPid = null;
|
|
1958
1972
|
if (!isAlreadyListening.listening) {
|
|
@@ -1986,7 +2000,15 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1986
2000
|
|
|
1987
2001
|
if (serverPid) {
|
|
1988
2002
|
console.log(' Server started.');
|
|
1989
|
-
config.
|
|
2003
|
+
const existingPids = (config.getOnboarding().server_pids || []).filter(p => {
|
|
2004
|
+
try { process.kill(p, 0); return true; } catch (e) { return false; }
|
|
2005
|
+
});
|
|
2006
|
+
if (!existingPids.includes(serverPid)) existingPids.push(serverPid);
|
|
2007
|
+
config.setOnboarding({
|
|
2008
|
+
server_pid: serverPid,
|
|
2009
|
+
server_pids: existingPids,
|
|
2010
|
+
server_port: serverPort
|
|
2011
|
+
});
|
|
1990
2012
|
} else {
|
|
1991
2013
|
console.log(' Using existing server.');
|
|
1992
2014
|
}
|
|
@@ -2317,24 +2339,43 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
2317
2339
|
}
|
|
2318
2340
|
}
|
|
2319
2341
|
|
|
2320
|
-
// Kill server by PID from config (detached process started by quickstart)
|
|
2342
|
+
// Kill server by PID from config and PID file (detached process started by quickstart)
|
|
2321
2343
|
// Then verify the port is actually freed; if not, find and kill whatever holds it.
|
|
2322
2344
|
async function killServerPid() {
|
|
2323
|
-
let pid, serverPort;
|
|
2345
|
+
let pid, serverPort, serverPids = [];
|
|
2324
2346
|
try {
|
|
2325
2347
|
const { A2AConfig } = require('../src/lib/config');
|
|
2326
2348
|
const cfg = new A2AConfig();
|
|
2327
2349
|
const onboarding = cfg.getOnboarding();
|
|
2328
2350
|
pid = onboarding.server_pid;
|
|
2329
2351
|
serverPort = onboarding.server_port;
|
|
2352
|
+
serverPids = Array.isArray(onboarding.server_pids) ? onboarding.server_pids : [];
|
|
2330
2353
|
} catch (err) {
|
|
2331
|
-
// Config read failed — not fatal, continue
|
|
2332
|
-
return { ok: true, skipped: true };
|
|
2354
|
+
// Config read failed — not fatal, continue
|
|
2333
2355
|
}
|
|
2334
2356
|
|
|
2335
|
-
// Step
|
|
2336
|
-
|
|
2337
|
-
|
|
2357
|
+
// Step 0: Try PID file first (most reliable source)
|
|
2358
|
+
try {
|
|
2359
|
+
const { readPidFile, removePidFile } = require('../src/lib/pid-file');
|
|
2360
|
+
const filePid = readPidFile();
|
|
2361
|
+
if (filePid) {
|
|
2362
|
+
killPidSync(filePid);
|
|
2363
|
+
removePidFile();
|
|
2364
|
+
// If config PID is the same, don't double-kill
|
|
2365
|
+
if (filePid === pid) pid = null;
|
|
2366
|
+
}
|
|
2367
|
+
} catch (e) {
|
|
2368
|
+
// pid-file module load failed — continue with config PID
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// Step 1: Try to kill all tracked PIDs from config
|
|
2372
|
+
const allPids = new Set();
|
|
2373
|
+
if (pid) allPids.add(pid);
|
|
2374
|
+
for (const p of serverPids) {
|
|
2375
|
+
if (typeof p === 'number' && p > 0) allPids.add(p);
|
|
2376
|
+
}
|
|
2377
|
+
for (const p of allPids) {
|
|
2378
|
+
killPidSync(p);
|
|
2338
2379
|
}
|
|
2339
2380
|
|
|
2340
2381
|
// Step 2: Verify the port is freed
|
|
@@ -2358,7 +2399,13 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
2358
2399
|
}
|
|
2359
2400
|
}
|
|
2360
2401
|
|
|
2361
|
-
|
|
2402
|
+
// Clean up PID file if it still exists
|
|
2403
|
+
try {
|
|
2404
|
+
const { removePidFile } = require('../src/lib/pid-file');
|
|
2405
|
+
removePidFile();
|
|
2406
|
+
} catch (e) {}
|
|
2407
|
+
|
|
2408
|
+
return { ok: true, pid, port: serverPort, skipped: !pid && allPids.size === 0 };
|
|
2362
2409
|
}
|
|
2363
2410
|
|
|
2364
2411
|
process.stdout.write('Stopping server... ');
|
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
# Orphaned Server Process Fix (A2A-25) Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Prevent `a2a quickstart` from leaking orphaned server processes by adding PID file tracking, pre-start cleanup, and robust uninstall scanning.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Four complementary changes: (1) Server writes a PID file on startup and cleans it on exit, (2) Quickstart reads the PID file and kills any existing server before spawning a new one, (3) Uninstall scans for ALL a2a server processes (not just the config PID), (4) Config tracks multiple PIDs for defense-in-depth. All changes are in three files: `src/server.js`, `bin/cli.js`, and a new test file.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js built-ins (`fs`, `path`, `process`, `child_process`), existing zero-dependency test runner at `test/run.js`, existing test helpers at `test/helpers.js`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Key Constants
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
PID file location: CONFIG_DIR/a2a-server.pid
|
|
17
|
+
CONFIG_DIR = process.env.A2A_CONFIG_DIR || ~/.config/openclaw
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### Task 1: Write the PID file module
|
|
23
|
+
|
|
24
|
+
**Files:**
|
|
25
|
+
- Create: `src/lib/pid-file.js`
|
|
26
|
+
- Test: `test/unit/pid-file.test.js`
|
|
27
|
+
|
|
28
|
+
**Step 1: Write the failing tests**
|
|
29
|
+
|
|
30
|
+
Create `test/unit/pid-file.test.js`:
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
/**
|
|
34
|
+
* PID File Tests
|
|
35
|
+
*
|
|
36
|
+
* Covers: writePidFile, readPidFile, removePidFile, isProcessAlive, killExistingServer
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
module.exports = function (test, assert, helpers) {
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const { spawn } = require('child_process');
|
|
43
|
+
const net = require('net');
|
|
44
|
+
|
|
45
|
+
// Fresh require helper — pid-file reads CONFIG_DIR at require time
|
|
46
|
+
function requirePidFile(configDir) {
|
|
47
|
+
// Clear cached module so it picks up the new A2A_CONFIG_DIR
|
|
48
|
+
const modPath = require.resolve('../../src/lib/pid-file');
|
|
49
|
+
delete require.cache[modPath];
|
|
50
|
+
process.env.A2A_CONFIG_DIR = configDir;
|
|
51
|
+
return require('../../src/lib/pid-file');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('writePidFile writes PID to a2a-server.pid', () => {
|
|
55
|
+
const tmp = helpers.tmpConfigDir('pid-write');
|
|
56
|
+
const pf = requirePidFile(tmp.dir);
|
|
57
|
+
|
|
58
|
+
pf.writePidFile(12345);
|
|
59
|
+
|
|
60
|
+
const pidPath = path.join(tmp.dir, 'a2a-server.pid');
|
|
61
|
+
assert.ok(fs.existsSync(pidPath), 'PID file should exist');
|
|
62
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
63
|
+
assert.equal(content, '12345', 'PID file should contain the PID');
|
|
64
|
+
|
|
65
|
+
tmp.cleanup();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('readPidFile returns the PID as a number', () => {
|
|
69
|
+
const tmp = helpers.tmpConfigDir('pid-read');
|
|
70
|
+
const pf = requirePidFile(tmp.dir);
|
|
71
|
+
|
|
72
|
+
fs.writeFileSync(path.join(tmp.dir, 'a2a-server.pid'), '42\n');
|
|
73
|
+
const pid = pf.readPidFile();
|
|
74
|
+
assert.equal(pid, 42, 'Should parse PID as number');
|
|
75
|
+
|
|
76
|
+
tmp.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('readPidFile returns null when file missing', () => {
|
|
80
|
+
const tmp = helpers.tmpConfigDir('pid-read-missing');
|
|
81
|
+
const pf = requirePidFile(tmp.dir);
|
|
82
|
+
|
|
83
|
+
const pid = pf.readPidFile();
|
|
84
|
+
assert.equal(pid, null, 'Should return null when no PID file');
|
|
85
|
+
|
|
86
|
+
tmp.cleanup();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('readPidFile returns null for corrupt content', () => {
|
|
90
|
+
const tmp = helpers.tmpConfigDir('pid-read-corrupt');
|
|
91
|
+
const pf = requirePidFile(tmp.dir);
|
|
92
|
+
|
|
93
|
+
fs.writeFileSync(path.join(tmp.dir, 'a2a-server.pid'), 'not-a-number\n');
|
|
94
|
+
const pid = pf.readPidFile();
|
|
95
|
+
assert.equal(pid, null, 'Should return null for non-numeric content');
|
|
96
|
+
|
|
97
|
+
tmp.cleanup();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('removePidFile deletes the file', () => {
|
|
101
|
+
const tmp = helpers.tmpConfigDir('pid-remove');
|
|
102
|
+
const pf = requirePidFile(tmp.dir);
|
|
103
|
+
|
|
104
|
+
const pidPath = path.join(tmp.dir, 'a2a-server.pid');
|
|
105
|
+
fs.writeFileSync(pidPath, '99\n');
|
|
106
|
+
pf.removePidFile();
|
|
107
|
+
assert.ok(!fs.existsSync(pidPath), 'PID file should be removed');
|
|
108
|
+
|
|
109
|
+
tmp.cleanup();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('removePidFile is safe when file does not exist', () => {
|
|
113
|
+
const tmp = helpers.tmpConfigDir('pid-remove-noop');
|
|
114
|
+
const pf = requirePidFile(tmp.dir);
|
|
115
|
+
|
|
116
|
+
// Should not throw
|
|
117
|
+
pf.removePidFile();
|
|
118
|
+
|
|
119
|
+
tmp.cleanup();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('isProcessAlive returns true for current process', () => {
|
|
123
|
+
const tmp = helpers.tmpConfigDir('pid-alive');
|
|
124
|
+
const pf = requirePidFile(tmp.dir);
|
|
125
|
+
|
|
126
|
+
assert.ok(pf.isProcessAlive(process.pid), 'Current process should be alive');
|
|
127
|
+
|
|
128
|
+
tmp.cleanup();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('isProcessAlive returns false for non-existent PID', () => {
|
|
132
|
+
const tmp = helpers.tmpConfigDir('pid-dead');
|
|
133
|
+
const pf = requirePidFile(tmp.dir);
|
|
134
|
+
|
|
135
|
+
assert.ok(!pf.isProcessAlive(999999999), 'Fake PID should not be alive');
|
|
136
|
+
|
|
137
|
+
tmp.cleanup();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('killExistingServer kills a live process from PID file', async () => {
|
|
141
|
+
const tmp = helpers.tmpConfigDir('pid-kill');
|
|
142
|
+
const pf = requirePidFile(tmp.dir);
|
|
143
|
+
|
|
144
|
+
// Spawn a detached sleep process
|
|
145
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], {
|
|
146
|
+
detached: true,
|
|
147
|
+
stdio: 'ignore'
|
|
148
|
+
});
|
|
149
|
+
child.unref();
|
|
150
|
+
const pid = child.pid;
|
|
151
|
+
|
|
152
|
+
// Write PID file
|
|
153
|
+
pf.writePidFile(pid);
|
|
154
|
+
|
|
155
|
+
// Kill it
|
|
156
|
+
const result = pf.killExistingServer();
|
|
157
|
+
assert.ok(result.killed, 'Should report killed');
|
|
158
|
+
assert.equal(result.pid, pid, 'Should report the PID');
|
|
159
|
+
|
|
160
|
+
// Verify dead
|
|
161
|
+
await new Promise(r => setTimeout(r, 200));
|
|
162
|
+
assert.ok(!pf.isProcessAlive(pid), 'Process should be dead');
|
|
163
|
+
|
|
164
|
+
tmp.cleanup();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('killExistingServer returns no-op when no PID file', () => {
|
|
168
|
+
const tmp = helpers.tmpConfigDir('pid-kill-noop');
|
|
169
|
+
const pf = requirePidFile(tmp.dir);
|
|
170
|
+
|
|
171
|
+
const result = pf.killExistingServer();
|
|
172
|
+
assert.ok(!result.killed, 'Should not report killed');
|
|
173
|
+
|
|
174
|
+
tmp.cleanup();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('killExistingServer returns no-op when PID is stale', () => {
|
|
178
|
+
const tmp = helpers.tmpConfigDir('pid-kill-stale');
|
|
179
|
+
const pf = requirePidFile(tmp.dir);
|
|
180
|
+
|
|
181
|
+
fs.writeFileSync(path.join(tmp.dir, 'a2a-server.pid'), '999999999\n');
|
|
182
|
+
const result = pf.killExistingServer();
|
|
183
|
+
assert.ok(!result.killed, 'Should not report killed for dead process');
|
|
184
|
+
|
|
185
|
+
// PID file should be cleaned up
|
|
186
|
+
assert.equal(pf.readPidFile(), null, 'Stale PID file should be removed');
|
|
187
|
+
|
|
188
|
+
tmp.cleanup();
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Step 2: Run tests to verify they fail**
|
|
194
|
+
|
|
195
|
+
Run: `node test/run.js --filter pid-file`
|
|
196
|
+
Expected: FAIL — module `src/lib/pid-file` does not exist
|
|
197
|
+
|
|
198
|
+
**Step 3: Write the implementation**
|
|
199
|
+
|
|
200
|
+
Create `src/lib/pid-file.js`:
|
|
201
|
+
|
|
202
|
+
```javascript
|
|
203
|
+
/**
|
|
204
|
+
* PID File Management
|
|
205
|
+
*
|
|
206
|
+
* Writes/reads/removes a PID file for the A2A server process.
|
|
207
|
+
* Used by server.js on startup and cli.js for pre-start cleanup.
|
|
208
|
+
*/
|
|
209
|
+
|
|
210
|
+
const fs = require('fs');
|
|
211
|
+
const path = require('path');
|
|
212
|
+
|
|
213
|
+
const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
214
|
+
process.env.OPENCLAW_CONFIG_DIR ||
|
|
215
|
+
path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
|
|
216
|
+
|
|
217
|
+
const PID_FILE = path.join(CONFIG_DIR, 'a2a-server.pid');
|
|
218
|
+
|
|
219
|
+
function writePidFile(pid) {
|
|
220
|
+
try {
|
|
221
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
222
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
223
|
+
}
|
|
224
|
+
fs.writeFileSync(PID_FILE, String(pid) + '\n', { mode: 0o600 });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// Best effort — don't crash the server if PID file write fails
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readPidFile() {
|
|
231
|
+
try {
|
|
232
|
+
const content = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
233
|
+
const pid = parseInt(content, 10);
|
|
234
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function removePidFile() {
|
|
241
|
+
try {
|
|
242
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
// Best effort
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isProcessAlive(pid) {
|
|
249
|
+
try {
|
|
250
|
+
process.kill(pid, 0);
|
|
251
|
+
return true;
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Kill the server process recorded in the PID file.
|
|
259
|
+
* Returns { killed: boolean, pid: number|null }.
|
|
260
|
+
*/
|
|
261
|
+
function killExistingServer() {
|
|
262
|
+
const pid = readPidFile();
|
|
263
|
+
if (!pid) return { killed: false, pid: null };
|
|
264
|
+
|
|
265
|
+
if (!isProcessAlive(pid)) {
|
|
266
|
+
// Stale PID file — clean up
|
|
267
|
+
removePidFile();
|
|
268
|
+
return { killed: false, pid };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
process.kill(pid, 'SIGTERM');
|
|
273
|
+
} catch (e) {
|
|
274
|
+
removePidFile();
|
|
275
|
+
return { killed: false, pid };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Wait up to 3s for graceful exit
|
|
279
|
+
const start = Date.now();
|
|
280
|
+
while (Date.now() - start < 3000) {
|
|
281
|
+
if (!isProcessAlive(pid)) {
|
|
282
|
+
removePidFile();
|
|
283
|
+
return { killed: true, pid };
|
|
284
|
+
}
|
|
285
|
+
// Busy-wait in small increments (sync — this runs before server spawn)
|
|
286
|
+
const { spawnSync } = require('child_process');
|
|
287
|
+
spawnSync('sleep', ['0.1'], { timeout: 500 });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Force kill
|
|
291
|
+
try {
|
|
292
|
+
process.kill(pid, 'SIGKILL');
|
|
293
|
+
} catch (e) {}
|
|
294
|
+
|
|
295
|
+
removePidFile();
|
|
296
|
+
return { killed: true, pid };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
writePidFile,
|
|
301
|
+
readPidFile,
|
|
302
|
+
removePidFile,
|
|
303
|
+
isProcessAlive,
|
|
304
|
+
killExistingServer,
|
|
305
|
+
PID_FILE
|
|
306
|
+
};
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Step 4: Run tests to verify they pass**
|
|
310
|
+
|
|
311
|
+
Run: `node test/run.js --filter pid-file`
|
|
312
|
+
Expected: All 10 tests PASS
|
|
313
|
+
|
|
314
|
+
**Step 5: Also run the full suite to check for regressions**
|
|
315
|
+
|
|
316
|
+
Run: `node test/run.js`
|
|
317
|
+
Expected: All 285+ tests PASS
|
|
318
|
+
|
|
319
|
+
**Step 6: Commit**
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
git add src/lib/pid-file.js test/unit/pid-file.test.js
|
|
323
|
+
git commit -m "feat(pid-file): add PID file management module with tests (A2A-25)"
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
### Task 2: Server writes PID file on startup, cleans on exit
|
|
329
|
+
|
|
330
|
+
**Files:**
|
|
331
|
+
- Modify: `src/server.js:895-922` (startup and end of file)
|
|
332
|
+
- Test: `test/unit/pid-file.test.js` (add integration-style test)
|
|
333
|
+
|
|
334
|
+
**Step 1: Write the failing test**
|
|
335
|
+
|
|
336
|
+
Add to `test/unit/pid-file.test.js`:
|
|
337
|
+
|
|
338
|
+
```javascript
|
|
339
|
+
test('server.js writes PID file on startup and removes on exit', async () => {
|
|
340
|
+
const tmp = helpers.tmpConfigDir('pid-server-lifecycle');
|
|
341
|
+
const pidPath = path.join(tmp.dir, 'a2a-server.pid');
|
|
342
|
+
const { spawnSync } = require('child_process');
|
|
343
|
+
|
|
344
|
+
// Start the real server with a random high port
|
|
345
|
+
const port = 19870 + Math.floor(Math.random() * 100);
|
|
346
|
+
const child = spawn(process.execPath, [
|
|
347
|
+
path.join(__dirname, '../../src/server.js')
|
|
348
|
+
], {
|
|
349
|
+
env: { ...process.env, A2A_CONFIG_DIR: tmp.dir, PORT: String(port) },
|
|
350
|
+
detached: true,
|
|
351
|
+
stdio: 'ignore'
|
|
352
|
+
});
|
|
353
|
+
child.unref();
|
|
354
|
+
|
|
355
|
+
// Wait for server to start and write PID file
|
|
356
|
+
let pidWritten = false;
|
|
357
|
+
for (let i = 0; i < 30; i++) {
|
|
358
|
+
if (fs.existsSync(pidPath)) {
|
|
359
|
+
pidWritten = true;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
await new Promise(r => setTimeout(r, 200));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
assert.ok(pidWritten, 'Server should write PID file on startup');
|
|
366
|
+
const writtenPid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
|
|
367
|
+
assert.equal(writtenPid, child.pid, 'PID file should contain server PID');
|
|
368
|
+
|
|
369
|
+
// Send SIGTERM and verify PID file is cleaned up
|
|
370
|
+
process.kill(child.pid, 'SIGTERM');
|
|
371
|
+
let pidRemoved = false;
|
|
372
|
+
for (let i = 0; i < 30; i++) {
|
|
373
|
+
if (!fs.existsSync(pidPath)) {
|
|
374
|
+
pidRemoved = true;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
await new Promise(r => setTimeout(r, 200));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
assert.ok(pidRemoved, 'Server should remove PID file on SIGTERM');
|
|
381
|
+
|
|
382
|
+
// Cleanup: ensure process is dead
|
|
383
|
+
try { process.kill(child.pid, 'SIGKILL'); } catch (e) {}
|
|
384
|
+
|
|
385
|
+
tmp.cleanup();
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Step 2: Run the new test to verify it fails**
|
|
390
|
+
|
|
391
|
+
Run: `node test/run.js --filter pid-file`
|
|
392
|
+
Expected: The new lifecycle test FAILS because server.js doesn't write a PID file yet
|
|
393
|
+
|
|
394
|
+
**Step 3: Modify `src/server.js`**
|
|
395
|
+
|
|
396
|
+
Add PID file write after `app.listen()` callback (after line 906) and signal handlers at end of `startServer()`:
|
|
397
|
+
|
|
398
|
+
At the top of `src/server.js` (after the existing requires around line 24), add:
|
|
399
|
+
|
|
400
|
+
```javascript
|
|
401
|
+
const { writePidFile, removePidFile } = require('./lib/pid-file');
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Inside the `app.listen()` callback (after the logger.info call at line 905), add:
|
|
405
|
+
|
|
406
|
+
```javascript
|
|
407
|
+
writePidFile(process.pid);
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Before the closing `}` of `startServer()` (before line 920), add signal handlers:
|
|
411
|
+
|
|
412
|
+
```javascript
|
|
413
|
+
// Graceful shutdown: clean up PID file
|
|
414
|
+
function shutdown(signal) {
|
|
415
|
+
removePidFile();
|
|
416
|
+
server.close(() => process.exit(0));
|
|
417
|
+
// Force exit after 5s if connections won't close
|
|
418
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
419
|
+
}
|
|
420
|
+
process.on('SIGTERM', shutdown);
|
|
421
|
+
process.on('SIGINT', shutdown);
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**Step 4: Run tests to verify they pass**
|
|
425
|
+
|
|
426
|
+
Run: `node test/run.js --filter pid-file`
|
|
427
|
+
Expected: All tests PASS including the lifecycle test
|
|
428
|
+
|
|
429
|
+
**Step 5: Run full suite**
|
|
430
|
+
|
|
431
|
+
Run: `node test/run.js`
|
|
432
|
+
Expected: All 285+ tests PASS
|
|
433
|
+
|
|
434
|
+
**Step 6: Commit**
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
git add src/server.js test/unit/pid-file.test.js
|
|
438
|
+
git commit -m "feat(server): write PID file on startup, remove on shutdown (A2A-25)"
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
### Task 3: Pre-start cleanup in quickstart
|
|
444
|
+
|
|
445
|
+
**Files:**
|
|
446
|
+
- Modify: `bin/cli.js:1956-1966` (before the `spawn` call in quickstart)
|
|
447
|
+
- Test: `test/unit/pid-file.test.js` (add pre-start test)
|
|
448
|
+
|
|
449
|
+
**Step 1: Write the failing test**
|
|
450
|
+
|
|
451
|
+
Add to `test/unit/pid-file.test.js`:
|
|
452
|
+
|
|
453
|
+
```javascript
|
|
454
|
+
test('quickstart kills existing server before starting a new one', async () => {
|
|
455
|
+
const tmp = helpers.tmpConfigDir('pid-prestart');
|
|
456
|
+
const pf = requirePidFile(tmp.dir);
|
|
457
|
+
const pidPath = path.join(tmp.dir, 'a2a-server.pid');
|
|
458
|
+
|
|
459
|
+
// Spawn a fake "old server" (just a sleep process)
|
|
460
|
+
const oldServer = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], {
|
|
461
|
+
detached: true,
|
|
462
|
+
stdio: 'ignore'
|
|
463
|
+
});
|
|
464
|
+
oldServer.unref();
|
|
465
|
+
const oldPid = oldServer.pid;
|
|
466
|
+
|
|
467
|
+
// Write its PID to the PID file (simulating a previous quickstart)
|
|
468
|
+
pf.writePidFile(oldPid);
|
|
469
|
+
assert.ok(pf.isProcessAlive(oldPid), 'Old server should be alive');
|
|
470
|
+
|
|
471
|
+
// Call killExistingServer (what quickstart will do)
|
|
472
|
+
const result = pf.killExistingServer();
|
|
473
|
+
assert.ok(result.killed, 'Should kill the old server');
|
|
474
|
+
|
|
475
|
+
await new Promise(r => setTimeout(r, 200));
|
|
476
|
+
assert.ok(!pf.isProcessAlive(oldPid), 'Old server should be dead after cleanup');
|
|
477
|
+
assert.equal(pf.readPidFile(), null, 'PID file should be cleaned up');
|
|
478
|
+
|
|
479
|
+
tmp.cleanup();
|
|
480
|
+
});
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Step 2: Run test to verify it passes (it should — killExistingServer already works)**
|
|
484
|
+
|
|
485
|
+
Run: `node test/run.js --filter pid-file`
|
|
486
|
+
Expected: PASS — this is testing the module integration, the next step wires it into cli.js
|
|
487
|
+
|
|
488
|
+
**Step 3: Modify `bin/cli.js` quickstart to call pre-start cleanup**
|
|
489
|
+
|
|
490
|
+
In `bin/cli.js`, around line 1958 (after the `isAlreadyListening` check, before the spawn block), add pre-start cleanup:
|
|
491
|
+
|
|
492
|
+
Replace the block at lines 1956-1968:
|
|
493
|
+
```javascript
|
|
494
|
+
const isAlreadyListening = await isPortListening(serverPort, '127.0.0.1', { timeoutMs: 250 });
|
|
495
|
+
let serverPid = null;
|
|
496
|
+
if (!isAlreadyListening.listening) {
|
|
497
|
+
const serverScript = path.join(__dirname, '../src/server.js');
|
|
498
|
+
const child = spawn(process.execPath, [serverScript], {
|
|
499
|
+
env: { ...process.env, PORT: String(serverPort) },
|
|
500
|
+
detached: true,
|
|
501
|
+
stdio: 'ignore'
|
|
502
|
+
});
|
|
503
|
+
serverPid = child.pid;
|
|
504
|
+
child.unref();
|
|
505
|
+
} else {
|
|
506
|
+
console.log(' Existing server detected on this port.');
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
With:
|
|
511
|
+
```javascript
|
|
512
|
+
// Pre-start cleanup: kill any existing a2a server from a previous run
|
|
513
|
+
try {
|
|
514
|
+
const { killExistingServer } = require('../src/lib/pid-file');
|
|
515
|
+
const cleanup = killExistingServer();
|
|
516
|
+
if (cleanup.killed) {
|
|
517
|
+
console.log(` Stopped previous server (PID ${cleanup.pid}).`);
|
|
518
|
+
// Brief pause to let the port free up
|
|
519
|
+
await new Promise(r => setTimeout(r, 500));
|
|
520
|
+
}
|
|
521
|
+
} catch (e) {
|
|
522
|
+
// Best effort — continue with startup
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const isAlreadyListening = await isPortListening(serverPort, '127.0.0.1', { timeoutMs: 250 });
|
|
526
|
+
let serverPid = null;
|
|
527
|
+
if (!isAlreadyListening.listening) {
|
|
528
|
+
const serverScript = path.join(__dirname, '../src/server.js');
|
|
529
|
+
const child = spawn(process.execPath, [serverScript], {
|
|
530
|
+
env: { ...process.env, PORT: String(serverPort) },
|
|
531
|
+
detached: true,
|
|
532
|
+
stdio: 'ignore'
|
|
533
|
+
});
|
|
534
|
+
serverPid = child.pid;
|
|
535
|
+
child.unref();
|
|
536
|
+
} else {
|
|
537
|
+
console.log(' Existing server detected on this port.');
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Step 4: Run full suite**
|
|
542
|
+
|
|
543
|
+
Run: `node test/run.js`
|
|
544
|
+
Expected: All tests PASS
|
|
545
|
+
|
|
546
|
+
**Step 5: Commit**
|
|
547
|
+
|
|
548
|
+
```bash
|
|
549
|
+
git add bin/cli.js
|
|
550
|
+
git commit -m "feat(quickstart): kill existing server before spawning new one (A2A-25)"
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
### Task 4: Uninstall scans for all a2a server processes
|
|
556
|
+
|
|
557
|
+
**Files:**
|
|
558
|
+
- Modify: `bin/cli.js:2320-2362` (killServerPid function in uninstall)
|
|
559
|
+
- Test: `test/unit/kill-server.test.js` (add PID file-aware test)
|
|
560
|
+
|
|
561
|
+
**Step 1: Write the failing test**
|
|
562
|
+
|
|
563
|
+
Add to `test/unit/kill-server.test.js`:
|
|
564
|
+
|
|
565
|
+
```javascript
|
|
566
|
+
test('uninstall --force reads PID file and kills the server', async () => {
|
|
567
|
+
const port = 19878;
|
|
568
|
+
const tmp = helpers.tmpConfigDir('kill-server-pidfile');
|
|
569
|
+
|
|
570
|
+
// Spawn a real server on the port
|
|
571
|
+
const pid = spawnDetachedServer(port);
|
|
572
|
+
const up = await waitForPort(port, 5000);
|
|
573
|
+
assert.ok(up, `Server should be listening on port ${port}`);
|
|
574
|
+
|
|
575
|
+
// Write PID file (what server.js now does)
|
|
576
|
+
const pidPath = path.join(tmp.dir, 'a2a-server.pid');
|
|
577
|
+
fs.writeFileSync(pidPath, String(pid) + '\n');
|
|
578
|
+
|
|
579
|
+
// Write config with NO server_pid (simulating PID file as primary mechanism)
|
|
580
|
+
const configPath = path.join(tmp.dir, 'a2a-config.json');
|
|
581
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
582
|
+
onboarding: { version: 2, server_port: port }
|
|
583
|
+
}));
|
|
584
|
+
|
|
585
|
+
// Run uninstall --force
|
|
586
|
+
const res = spawnSync(process.execPath, ['bin/cli.js', 'uninstall', '--force'], {
|
|
587
|
+
env: { ...process.env, A2A_CONFIG_DIR: tmp.dir },
|
|
588
|
+
encoding: 'utf8',
|
|
589
|
+
timeout: 20000
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
assert.equal(res.status, 0, `Expected exit 0, got ${res.status}. stderr=${(res.stderr || '').trim()}`);
|
|
593
|
+
|
|
594
|
+
// Verify port is freed
|
|
595
|
+
const free = await isPortFree(port);
|
|
596
|
+
assert.ok(free, `Port ${port} should be free after uninstall`);
|
|
597
|
+
|
|
598
|
+
// Verify PID file is cleaned up
|
|
599
|
+
assert.ok(!fs.existsSync(pidPath), 'PID file should be removed after uninstall');
|
|
600
|
+
|
|
601
|
+
tmp.cleanup();
|
|
602
|
+
});
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
**Step 2: Run test to verify it fails**
|
|
606
|
+
|
|
607
|
+
Run: `node test/run.js --filter kill-server`
|
|
608
|
+
Expected: The new test may pass (port-based fallback might save it) or fail if PID file cleanup is checked. Either way, we need to wire PID file into uninstall.
|
|
609
|
+
|
|
610
|
+
**Step 3: Modify `killServerPid` in uninstall**
|
|
611
|
+
|
|
612
|
+
In `bin/cli.js`, modify the `killServerPid()` function (around line 2322) to also check the PID file as an additional kill source:
|
|
613
|
+
|
|
614
|
+
Replace the beginning of `killServerPid()`:
|
|
615
|
+
|
|
616
|
+
```javascript
|
|
617
|
+
async function killServerPid() {
|
|
618
|
+
let pid, serverPort;
|
|
619
|
+
try {
|
|
620
|
+
const { A2AConfig } = require('../src/lib/config');
|
|
621
|
+
const cfg = new A2AConfig();
|
|
622
|
+
const onboarding = cfg.getOnboarding();
|
|
623
|
+
pid = onboarding.server_pid;
|
|
624
|
+
serverPort = onboarding.server_port;
|
|
625
|
+
} catch (err) {
|
|
626
|
+
// Config read failed — not fatal, continue with pm2 path
|
|
627
|
+
return { ok: true, skipped: true };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Step 1: Try to kill the PID from config
|
|
631
|
+
if (pid) {
|
|
632
|
+
killPidSync(pid);
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
With:
|
|
637
|
+
|
|
638
|
+
```javascript
|
|
639
|
+
async function killServerPid() {
|
|
640
|
+
let pid, serverPort;
|
|
641
|
+
try {
|
|
642
|
+
const { A2AConfig } = require('../src/lib/config');
|
|
643
|
+
const cfg = new A2AConfig();
|
|
644
|
+
const onboarding = cfg.getOnboarding();
|
|
645
|
+
pid = onboarding.server_pid;
|
|
646
|
+
serverPort = onboarding.server_port;
|
|
647
|
+
} catch (err) {
|
|
648
|
+
// Config read failed — not fatal, continue
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Step 0: Try PID file first (most reliable source)
|
|
652
|
+
try {
|
|
653
|
+
const { readPidFile, removePidFile } = require('../src/lib/pid-file');
|
|
654
|
+
const filePid = readPidFile();
|
|
655
|
+
if (filePid) {
|
|
656
|
+
killPidSync(filePid);
|
|
657
|
+
removePidFile();
|
|
658
|
+
// If config PID is the same, don't double-kill
|
|
659
|
+
if (filePid === pid) pid = null;
|
|
660
|
+
}
|
|
661
|
+
} catch (e) {
|
|
662
|
+
// pid-file module load failed — continue with config PID
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Step 1: Try to kill the PID from config
|
|
666
|
+
if (pid) {
|
|
667
|
+
killPidSync(pid);
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
Also add PID file cleanup at the end — after the port check, before the return statements, add PID file removal. After the `return` statements in `killServerPid`, ensure PID file is always cleaned:
|
|
672
|
+
|
|
673
|
+
At the end of `killServerPid()`, before the final `return { ok: true, pid, port: serverPort, skipped: !pid };`, add:
|
|
674
|
+
|
|
675
|
+
```javascript
|
|
676
|
+
// Clean up PID file if it still exists
|
|
677
|
+
try {
|
|
678
|
+
const { removePidFile } = require('../src/lib/pid-file');
|
|
679
|
+
removePidFile();
|
|
680
|
+
} catch (e) {}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
**Step 4: Run tests**
|
|
684
|
+
|
|
685
|
+
Run: `node test/run.js --filter kill-server`
|
|
686
|
+
Expected: All tests PASS including the new PID file test
|
|
687
|
+
|
|
688
|
+
**Step 5: Run full suite**
|
|
689
|
+
|
|
690
|
+
Run: `node test/run.js`
|
|
691
|
+
Expected: All 285+ tests PASS
|
|
692
|
+
|
|
693
|
+
**Step 6: Commit**
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
git add bin/cli.js test/unit/kill-server.test.js
|
|
697
|
+
git commit -m "feat(uninstall): read PID file for robust server kill (A2A-25)"
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
### Task 5: Multi-PID tracking in config (defense-in-depth)
|
|
703
|
+
|
|
704
|
+
**Files:**
|
|
705
|
+
- Modify: `bin/cli.js:1987-1989` (save PID after spawn)
|
|
706
|
+
- Modify: `bin/cli.js:2328` (read PIDs in uninstall)
|
|
707
|
+
|
|
708
|
+
**Step 1: Write the failing test**
|
|
709
|
+
|
|
710
|
+
Add to `test/unit/kill-server.test.js`:
|
|
711
|
+
|
|
712
|
+
```javascript
|
|
713
|
+
test('uninstall --force kills multiple tracked PIDs from config', async () => {
|
|
714
|
+
const port1 = 19879;
|
|
715
|
+
const port2 = 19880;
|
|
716
|
+
const tmp = helpers.tmpConfigDir('kill-server-multi');
|
|
717
|
+
|
|
718
|
+
// Spawn two servers on different ports
|
|
719
|
+
const pid1 = spawnDetachedServer(port1);
|
|
720
|
+
const pid2 = spawnDetachedServer(port2);
|
|
721
|
+
const up1 = await waitForPort(port1, 5000);
|
|
722
|
+
const up2 = await waitForPort(port2, 5000);
|
|
723
|
+
assert.ok(up1, `Server 1 should be listening on port ${port1}`);
|
|
724
|
+
assert.ok(up2, `Server 2 should be listening on port ${port2}`);
|
|
725
|
+
|
|
726
|
+
// Write config with server_pids array
|
|
727
|
+
const configPath = path.join(tmp.dir, 'a2a-config.json');
|
|
728
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
729
|
+
onboarding: {
|
|
730
|
+
version: 2,
|
|
731
|
+
server_pid: pid2,
|
|
732
|
+
server_pids: [pid1, pid2],
|
|
733
|
+
server_port: port2
|
|
734
|
+
}
|
|
735
|
+
}));
|
|
736
|
+
|
|
737
|
+
const res = spawnSync(process.execPath, ['bin/cli.js', 'uninstall', '--force'], {
|
|
738
|
+
env: { ...process.env, A2A_CONFIG_DIR: tmp.dir },
|
|
739
|
+
encoding: 'utf8',
|
|
740
|
+
timeout: 20000
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
assert.equal(res.status, 0, `Expected exit 0, got ${res.status}`);
|
|
744
|
+
|
|
745
|
+
// Both ports should be freed
|
|
746
|
+
const free1 = await isPortFree(port1);
|
|
747
|
+
const free2 = await isPortFree(port2);
|
|
748
|
+
assert.ok(free1, `Port ${port1} should be free`);
|
|
749
|
+
assert.ok(free2, `Port ${port2} should be free`);
|
|
750
|
+
|
|
751
|
+
// Safety cleanup
|
|
752
|
+
try { process.kill(pid1, 'SIGKILL'); } catch (e) {}
|
|
753
|
+
try { process.kill(pid2, 'SIGKILL'); } catch (e) {}
|
|
754
|
+
|
|
755
|
+
tmp.cleanup();
|
|
756
|
+
});
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
**Step 2: Run test to verify it fails**
|
|
760
|
+
|
|
761
|
+
Run: `node test/run.js --filter kill-server`
|
|
762
|
+
Expected: FAIL — pid1's port won't be freed because uninstall only checks `server_pid` (singular) and the port for pid2
|
|
763
|
+
|
|
764
|
+
**Step 3: Modify quickstart to track `server_pids` array**
|
|
765
|
+
|
|
766
|
+
In `bin/cli.js`, replace line 1989:
|
|
767
|
+
|
|
768
|
+
```javascript
|
|
769
|
+
config.setOnboarding({ server_pid: serverPid, server_port: serverPort });
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
With:
|
|
773
|
+
|
|
774
|
+
```javascript
|
|
775
|
+
const existingPids = (config.getOnboarding().server_pids || []).filter(p => {
|
|
776
|
+
try { process.kill(p, 0); return true; } catch (e) { return false; }
|
|
777
|
+
});
|
|
778
|
+
if (!existingPids.includes(serverPid)) existingPids.push(serverPid);
|
|
779
|
+
config.setOnboarding({
|
|
780
|
+
server_pid: serverPid,
|
|
781
|
+
server_pids: existingPids,
|
|
782
|
+
server_port: serverPort
|
|
783
|
+
});
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
**Step 4: Modify uninstall to kill all tracked PIDs**
|
|
787
|
+
|
|
788
|
+
In `bin/cli.js`, in `killServerPid()`, after reading onboarding, modify the "Step 1: Try to kill the PID from config" section.
|
|
789
|
+
|
|
790
|
+
Replace:
|
|
791
|
+
|
|
792
|
+
```javascript
|
|
793
|
+
// Step 1: Try to kill the PID from config
|
|
794
|
+
if (pid) {
|
|
795
|
+
killPidSync(pid);
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
With:
|
|
800
|
+
|
|
801
|
+
```javascript
|
|
802
|
+
// Step 1: Try to kill all tracked PIDs from config
|
|
803
|
+
const allPids = new Set();
|
|
804
|
+
if (pid) allPids.add(pid);
|
|
805
|
+
try {
|
|
806
|
+
const { A2AConfig: A2AConfigReload } = require('../src/lib/config');
|
|
807
|
+
const cfgReload = new A2AConfigReload();
|
|
808
|
+
const ob = cfgReload.getOnboarding();
|
|
809
|
+
if (Array.isArray(ob.server_pids)) {
|
|
810
|
+
for (const p of ob.server_pids) {
|
|
811
|
+
if (typeof p === 'number' && p > 0) allPids.add(p);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
} catch (e) {}
|
|
815
|
+
|
|
816
|
+
for (const p of allPids) {
|
|
817
|
+
killPidSync(p);
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
Note: We already loaded config once at the top of `killServerPid` — since that section now might set `pid = null` after the PID file block, we should capture `server_pids` in the initial config read. Simpler approach: capture it in the initial try/catch:
|
|
822
|
+
|
|
823
|
+
In the initial config read block, after `serverPort = onboarding.server_port;` add:
|
|
824
|
+
|
|
825
|
+
```javascript
|
|
826
|
+
var serverPids = Array.isArray(onboarding.server_pids) ? onboarding.server_pids : [];
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
Then in Step 1, replace:
|
|
830
|
+
|
|
831
|
+
```javascript
|
|
832
|
+
// Step 1: Try to kill the PID from config
|
|
833
|
+
if (pid) {
|
|
834
|
+
killPidSync(pid);
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
With:
|
|
839
|
+
|
|
840
|
+
```javascript
|
|
841
|
+
// Step 1: Try to kill all tracked PIDs from config
|
|
842
|
+
const allPids = new Set();
|
|
843
|
+
if (pid) allPids.add(pid);
|
|
844
|
+
if (serverPids) {
|
|
845
|
+
for (const p of serverPids) {
|
|
846
|
+
if (typeof p === 'number' && p > 0) allPids.add(p);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const p of allPids) {
|
|
850
|
+
killPidSync(p);
|
|
851
|
+
}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
And initialize `serverPids` alongside `pid` and `serverPort` (default `[]`):
|
|
855
|
+
|
|
856
|
+
```javascript
|
|
857
|
+
let pid, serverPort, serverPids = [];
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
And in the config read:
|
|
861
|
+
|
|
862
|
+
```javascript
|
|
863
|
+
serverPids = Array.isArray(onboarding.server_pids) ? onboarding.server_pids : [];
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
**Step 5: Run tests**
|
|
867
|
+
|
|
868
|
+
Run: `node test/run.js --filter kill-server`
|
|
869
|
+
Expected: All tests PASS including multi-PID
|
|
870
|
+
|
|
871
|
+
**Step 6: Run full suite**
|
|
872
|
+
|
|
873
|
+
Run: `node test/run.js`
|
|
874
|
+
Expected: All 285+ tests PASS
|
|
875
|
+
|
|
876
|
+
**Step 7: Commit**
|
|
877
|
+
|
|
878
|
+
```bash
|
|
879
|
+
git add bin/cli.js test/unit/kill-server.test.js
|
|
880
|
+
git commit -m "feat(cli): track and kill multiple server PIDs (A2A-25)"
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
---
|
|
884
|
+
|
|
885
|
+
### Task 6: Final integration test — repeated quickstart doesn't leak
|
|
886
|
+
|
|
887
|
+
**Files:**
|
|
888
|
+
- Test: `test/unit/pid-file.test.js` (add end-to-end orphan test)
|
|
889
|
+
|
|
890
|
+
**Step 1: Write the integration test**
|
|
891
|
+
|
|
892
|
+
Add to `test/unit/pid-file.test.js`:
|
|
893
|
+
|
|
894
|
+
```javascript
|
|
895
|
+
test('repeated spawn-and-kill does not leak processes', async () => {
|
|
896
|
+
const tmp = helpers.tmpConfigDir('pid-no-leak');
|
|
897
|
+
const pf = requirePidFile(tmp.dir);
|
|
898
|
+
const pids = [];
|
|
899
|
+
|
|
900
|
+
// Simulate 3 quickstart runs: spawn → write PID → kill previous → spawn new
|
|
901
|
+
for (let i = 0; i < 3; i++) {
|
|
902
|
+
// Kill previous (what quickstart now does)
|
|
903
|
+
pf.killExistingServer();
|
|
904
|
+
|
|
905
|
+
// Spawn new
|
|
906
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], {
|
|
907
|
+
detached: true,
|
|
908
|
+
stdio: 'ignore'
|
|
909
|
+
});
|
|
910
|
+
child.unref();
|
|
911
|
+
pids.push(child.pid);
|
|
912
|
+
pf.writePidFile(child.pid);
|
|
913
|
+
|
|
914
|
+
await new Promise(r => setTimeout(r, 100));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Only the LAST process should be alive
|
|
918
|
+
await new Promise(r => setTimeout(r, 300));
|
|
919
|
+
for (let i = 0; i < pids.length - 1; i++) {
|
|
920
|
+
assert.ok(!pf.isProcessAlive(pids[i]), `Process ${i} (PID ${pids[i]}) should be dead`);
|
|
921
|
+
}
|
|
922
|
+
assert.ok(pf.isProcessAlive(pids[pids.length - 1]), 'Last process should be alive');
|
|
923
|
+
|
|
924
|
+
// Cleanup
|
|
925
|
+
pf.killExistingServer();
|
|
926
|
+
for (const pid of pids) {
|
|
927
|
+
try { process.kill(pid, 'SIGKILL'); } catch (e) {}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
tmp.cleanup();
|
|
931
|
+
});
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
**Step 2: Run to verify it passes**
|
|
935
|
+
|
|
936
|
+
Run: `node test/run.js --filter pid-file`
|
|
937
|
+
Expected: PASS — the full pipeline works
|
|
938
|
+
|
|
939
|
+
**Step 3: Run full suite one final time**
|
|
940
|
+
|
|
941
|
+
Run: `node test/run.js`
|
|
942
|
+
Expected: All tests PASS
|
|
943
|
+
|
|
944
|
+
**Step 4: Commit**
|
|
945
|
+
|
|
946
|
+
```bash
|
|
947
|
+
git add test/unit/pid-file.test.js
|
|
948
|
+
git commit -m "test: add repeated-quickstart orphan leak test (A2A-25)"
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
## Summary of Changes
|
|
954
|
+
|
|
955
|
+
| File | Change |
|
|
956
|
+
|------|--------|
|
|
957
|
+
| `src/lib/pid-file.js` | New module: write/read/remove PID file, kill existing server |
|
|
958
|
+
| `src/server.js` | Write PID file on startup, remove on SIGTERM/SIGINT |
|
|
959
|
+
| `bin/cli.js` (quickstart) | Call `killExistingServer()` before spawning new server |
|
|
960
|
+
| `bin/cli.js` (uninstall) | Read PID file + multi-PID array, kill all tracked processes |
|
|
961
|
+
| `test/unit/pid-file.test.js` | New: 12 tests covering PID file lifecycle |
|
|
962
|
+
| `test/unit/kill-server.test.js` | +2 tests: PID file kill, multi-PID kill |
|
package/package.json
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PID File Management
|
|
3
|
+
*
|
|
4
|
+
* Writes/reads/removes a PID file for the A2A server process.
|
|
5
|
+
* Used by server.js on startup and cli.js for pre-start cleanup.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
13
|
+
process.env.OPENCLAW_CONFIG_DIR ||
|
|
14
|
+
path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
|
|
15
|
+
|
|
16
|
+
const PID_FILE = path.join(CONFIG_DIR, 'a2a-server.pid');
|
|
17
|
+
|
|
18
|
+
function writePidFile(pid) {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
21
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
fs.writeFileSync(PID_FILE, String(pid) + '\n', { mode: 0o600 });
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Best effort — don't crash the server if PID file write fails
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPidFile() {
|
|
30
|
+
try {
|
|
31
|
+
const content = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
32
|
+
const pid = parseInt(content, 10);
|
|
33
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function removePidFile() {
|
|
40
|
+
try {
|
|
41
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Best effort
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isProcessAlive(pid) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Kill the server process recorded in the PID file.
|
|
58
|
+
* Returns { killed: boolean, pid: number|null }.
|
|
59
|
+
*/
|
|
60
|
+
function killExistingServer() {
|
|
61
|
+
const pid = readPidFile();
|
|
62
|
+
if (!pid) return { killed: false, pid: null };
|
|
63
|
+
|
|
64
|
+
if (!isProcessAlive(pid)) {
|
|
65
|
+
// Stale PID file — clean up
|
|
66
|
+
removePidFile();
|
|
67
|
+
return { killed: false, pid };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 'SIGTERM');
|
|
72
|
+
} catch (e) {
|
|
73
|
+
removePidFile();
|
|
74
|
+
return { killed: false, pid };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Wait up to 3s for graceful exit
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
while (Date.now() - start < 3000) {
|
|
80
|
+
if (!isProcessAlive(pid)) {
|
|
81
|
+
removePidFile();
|
|
82
|
+
return { killed: true, pid };
|
|
83
|
+
}
|
|
84
|
+
spawnSync('sleep', ['0.1'], { timeout: 500 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Force kill
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 'SIGKILL');
|
|
90
|
+
} catch (e) {}
|
|
91
|
+
|
|
92
|
+
removePidFile();
|
|
93
|
+
return { killed: true, pid };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
writePidFile,
|
|
98
|
+
readPidFile,
|
|
99
|
+
removePidFile,
|
|
100
|
+
isProcessAlive,
|
|
101
|
+
killExistingServer,
|
|
102
|
+
PID_FILE
|
|
103
|
+
};
|
package/src/server.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
} = require('./lib/prompt-template');
|
|
23
23
|
const { findAvailablePort } = require('./lib/port-scanner');
|
|
24
24
|
const { createLogger } = require('./lib/logger');
|
|
25
|
+
const { writePidFile, removePidFile } = require('./lib/pid-file');
|
|
25
26
|
|
|
26
27
|
const DEFAULT_PORTS = [80, 3001, 8080, 8443, 9001];
|
|
27
28
|
const requestedPort = process.env.PORT ? parseInt(process.env.PORT, 10)
|
|
@@ -903,6 +904,7 @@ async function startServer() {
|
|
|
903
904
|
features: ['adaptive collaboration', 'auto-contacts', 'summaries', 'dashboard']
|
|
904
905
|
}
|
|
905
906
|
});
|
|
907
|
+
writePidFile(process.pid);
|
|
906
908
|
});
|
|
907
909
|
|
|
908
910
|
server.on('error', (err) => {
|
|
@@ -917,6 +919,16 @@ async function startServer() {
|
|
|
917
919
|
}
|
|
918
920
|
throw err;
|
|
919
921
|
});
|
|
922
|
+
|
|
923
|
+
// Graceful shutdown: clean up PID file
|
|
924
|
+
function shutdown() {
|
|
925
|
+
removePidFile();
|
|
926
|
+
server.close(() => process.exit(0));
|
|
927
|
+
// Force exit after 5s if connections won't close
|
|
928
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
929
|
+
}
|
|
930
|
+
process.on('SIGTERM', shutdown);
|
|
931
|
+
process.on('SIGINT', shutdown);
|
|
920
932
|
}
|
|
921
933
|
|
|
922
934
|
startServer();
|