a2acalling 0.6.46 → 0.6.47
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 +2 -1
- 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/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('');
|
|
@@ -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
|
+
};
|