Skip to content

Commit a1a56cf

Browse files
ARHAEEMclaude
andcommitted
fix(daemon): require uuid proof for ALL kill escalation; restartDaemon aborts on failed stop
Round-3 review findings on PR #18, both verified real: 1. The accepted-shutdown branch treated any 2xx as identity proof — a catch-all local service on a stale lock's reused port can 200 a POST /daemon/shutdown while ignoring the bearer header, and after the lock-release wait expired the recorded (possibly recycled) pid was killed. The accepted/rejected distinction no longer matters for escalation: EVERY kill now requires /daemon/health (authenticated with the lockfile's bearer) to echo the lockfile's uuid. Costs nothing in the legit path: a healthy daemon dies during the wait, a wedged one still answers health. Graceful wait is test-tunable. 2. restartDaemon ignored stopDaemon's StopResult: when the old daemon survived SIGKILL (stopped:false leaves the lock in place), ensureDaemon would reconnect to that same wedged process and report a successful restart. It now throws with the stop reason; the restartDaemon command surfaces the error instead of claiming success. 3 new tests: impostor-2xx must not kill; wedged-but-proven daemon still escalates; restart aborts when stop fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9624aab commit a1a56cf

3 files changed

Lines changed: 89 additions & 15 deletions

File tree

packages/extension/src/extension.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,8 +571,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
571571
dashboardProvider.refresh();
572572
}),
573573
vscode.commands.registerCommand('airtable-formula.restartDaemon', async () => {
574-
await daemonManager.restartDaemon();
575-
vscode.window.showInformationMessage('Airtable Formula: Daemon restarted.');
574+
try {
575+
await daemonManager.restartDaemon();
576+
vscode.window.showInformationMessage('Airtable Formula: Daemon restarted.');
577+
} catch (err) {
578+
vscode.window.showErrorMessage(`Airtable Formula: Daemon restart failed — ${err instanceof Error ? err.message : String(err)}`);
579+
}
576580
dashboardProvider.refresh();
577581
}),
578582
vscode.commands.registerCommand('airtableFormula.tunnel.disable', async () => {

packages/extension/src/mcp/daemon-manager.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export class DaemonManager implements vscode.Disposable {
5151
* any explicit start/restart.
5252
*/
5353
private _userStopped = false;
54+
/** Graceful-shutdown wait before kill escalation (overridable in tests). */
55+
private _stopWaitMs = 10_000;
5456

5557
constructor(
5658
private readonly configDir: string,
@@ -185,28 +187,27 @@ export class DaemonManager implements vscode.Disposable {
185187

186188
// 2) Accepted — the daemon releases its lockfile as it exits; wait for it.
187189
if (outcome === 'accepted') {
188-
const deadline = Date.now() + 10_000;
190+
const deadline = Date.now() + this._stopWaitMs;
189191
while (Date.now() < deadline) {
190192
if (!this._lockfileExists()) return { stopped: true, forced: false };
191193
await this._delay(200);
192194
}
193-
// Daemon acknowledged but never exited — fall through to escalation.
195+
// Acknowledged but the lock never released — fall through to escalation.
194196
}
195197

196198
const pid = status.pid;
197199
const pidAlive = typeof pid === 'number' && pid > 0 && this._isPidAlive(pid);
198200

199-
// 3) Escalate to kill ONLY with proven daemon identity. An accepted
200-
// (bearer-authenticated) shutdown proves it. A rejected response does
201-
// NOT — it only proves SOMETHING answered on the port; a stale lock
202-
// whose port was reused by an unrelated HTTP service also rejects,
203-
// while the recorded pid may belong to an innocent recycled process.
204-
// For rejected, require /daemon/health to echo the lockfile's uuid.
205-
const provenOurDaemon = outcome === 'accepted' || (
206-
outcome === 'rejected'
201+
// 3) Escalate to kill ONLY with proven daemon identity: /daemon/health,
202+
// authenticated with the lockfile's bearer, echoing the lockfile's
203+
// uuid. NEITHER an accepted nor a rejected shutdown response proves
204+
// identity by itself — a stale lock whose port was reused by an
205+
// unrelated local service can produce either (catch-all routes 200
206+
// anything and ignore the bearer header), while the recorded pid may
207+
// belong to an innocent recycled process.
208+
const provenOurDaemon = outcome !== 'unreachable'
207209
&& status.port != null && status.bearerToken != null
208-
&& await this._verifyDaemonIdentity(status.port, status.bearerToken, status.uuid)
209-
);
210+
&& await this._verifyDaemonIdentity(status.port, status.bearerToken, status.uuid);
210211

211212
if (provenOurDaemon && pidAlive && typeof pid === 'number') {
212213
this._killPid(pid);
@@ -264,7 +265,12 @@ export class DaemonManager implements vscode.Disposable {
264265
}
265266

266267
async restartDaemon(): Promise<DaemonConnectionInfo> {
267-
await this.stopDaemon();
268+
const stop = await this.stopDaemon();
269+
if (!stop.stopped) {
270+
// Proceeding would let ensureDaemon() find the old daemon's lockfile
271+
// and "restart" by reconnecting to the very process that refused to die.
272+
throw new Error(`Restart aborted — the running daemon could not be stopped: ${stop.reason ?? 'unknown reason'}`);
273+
}
268274
await this._delay(500);
269275
return this.ensureDaemon();
270276
}

packages/extension/src/test/daemon-manager.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,49 @@ describe('DaemonManager.stopDaemon', () => {
177177
expect(lockExists()).toBe(false);
178178
});
179179

180+
it('accepted shutdown from an impostor (2xx, no uuid proof): does NOT kill, reclaims the lock', async () => {
181+
// A catch-all local service can 200 a POST /daemon/shutdown while
182+
// ignoring the bearer header — and it will never release our lockfile.
183+
const port = await listen((req, res) => {
184+
res.writeHead(200, { 'Content-Type': req.url === '/daemon/health' ? 'text/html' : 'application/json' });
185+
res.end(req.url === '/daemon/health' ? '<html>not the daemon</html>' : '{"ok":true}');
186+
});
187+
writeLock(port, process.pid);
188+
(dm as any)._stopWaitMs = 300;
189+
(dm as any)._killPid = vi.fn();
190+
(dm as any)._isPidAlive = vi.fn(() => true);
191+
192+
const result = await dm.stopDaemon();
193+
expect((dm as any)._killPid).not.toHaveBeenCalled();
194+
expect(result.stopped).toBe(true);
195+
expect(result.reason).toBeTruthy();
196+
expect(lockExists()).toBe(false);
197+
});
198+
199+
it('accepted shutdown but wedged daemon (uuid proven): escalates to kill', async () => {
200+
const port = await listen((req, res) => {
201+
if (req.method === 'GET' && req.url === '/daemon/health') {
202+
res.writeHead(200, { 'Content-Type': 'application/json' });
203+
res.end(JSON.stringify({ ok: true, uuid: 'uuid-1' }));
204+
return;
205+
}
206+
res.writeHead(200, { 'Content-Type': 'application/json' });
207+
res.end('{"ok":true}');
208+
// Wedged: never releases the lockfile
209+
});
210+
writeLock(port, 23456);
211+
(dm as any)._stopWaitMs = 300;
212+
let killed = false;
213+
(dm as any)._killPid = vi.fn(() => { killed = true; });
214+
(dm as any)._isPidAlive = vi.fn(() => !killed);
215+
216+
const result = await dm.stopDaemon();
217+
expect((dm as any)._killPid).toHaveBeenCalledWith(23456);
218+
expect(result.stopped).toBe(true);
219+
expect(result.forced).toBe(true);
220+
expect(lockExists()).toBe(false);
221+
});
222+
180223
it('unreachable daemon with dead pid: reclaims the stale lock without killing anything', async () => {
181224
writeLock(1, 999_999); // port 1 — nothing listening
182225
(dm as any)._killPid = vi.fn();
@@ -201,6 +244,27 @@ describe('DaemonManager.stopDaemon', () => {
201244
});
202245
});
203246

247+
describe('DaemonManager.restartDaemon', () => {
248+
let tmpDir: string;
249+
let dm: InstanceType<typeof DaemonManager>;
250+
251+
beforeEach(() => {
252+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-daemon-restart-'));
253+
dm = new DaemonManager(tmpDir, '/tmp/test-ext-path');
254+
});
255+
256+
afterEach(() => {
257+
fs.rmSync(tmpDir, { recursive: true, force: true });
258+
});
259+
260+
it('aborts (throws) when the running daemon could not be stopped', async () => {
261+
(dm as any).stopDaemon = vi.fn(async () => ({ stopped: false, forced: true, reason: 'process 1 did not exit' }));
262+
(dm as any)._spawnDetached = vi.fn();
263+
await expect(dm.restartDaemon()).rejects.toThrow(/could not be stopped/);
264+
expect((dm as any)._spawnDetached).not.toHaveBeenCalled();
265+
});
266+
});
267+
204268
describe('DaemonManager user-stopped latch', () => {
205269
let tmpDir: string;
206270
let dm: InstanceType<typeof DaemonManager>;

0 commit comments

Comments
 (0)