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 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
- console.log(` Invite host: ${statusData.invite_host}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.46",
3
+ "version": "0.6.47",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
+ };