From f6bcc5523a02d0b33b3f018e3dbcde65087e0ede Mon Sep 17 00:00:00 2001 From: SmashRex Date: Sat, 3 Oct 2026 17:57:37 +0100 Subject: [PATCH 1/8] fix: address Windows daemon kill review Signed-off-by: SmashRex --- server/src/command/cli/cli-kill.test.ts | 98 ++++++++++++-- server/src/command/cli/cli-kill.ts | 120 +++++++++++++++--- .../src/command/cli/ports/port-holder.test.ts | 45 ++++++- server/src/command/cli/ports/port-holder.ts | 39 +++++- .../command/daemon/binding/port-presence.ts | 20 +++ 5 files changed, 292 insertions(+), 30 deletions(-) diff --git a/server/src/command/cli/cli-kill.test.ts b/server/src/command/cli/cli-kill.test.ts index 2f2140852..46f413571 100644 --- a/server/src/command/cli/cli-kill.test.ts +++ b/server/src/command/cli/cli-kill.test.ts @@ -12,16 +12,62 @@ * every platform while the signalling stays at the call site. */ -import { describe, expect, it } from 'vitest'; -import { KillAction, planKill } from './cli-kill.js'; +import { describe, expect, it, vi } from 'vitest'; +import { KillAction, planKill, runKill } from './cli-kill.js'; + +vi.mock('@/command/daemon/daemon.js', () => ({ + isAlive: () => true, + readPid: () => null, + removePid: () => undefined, +})); + +vi.mock('@/command/daemon/binding/port-presence.js', () => ({ + PortPresence: { + DAEMON: 'daemon', + FOREIGN: 'foreign', + FREE: 'free', + }, + probePresence: vi.fn(), + probePresenceWithStatus: vi.fn(), +})); + +vi.mock('@/command/daemon/binding/daemon-status-probe.js', () => ({ + fetchStatus: vi.fn(), +})); + +vi.mock('@/surface/mcp/mcp-proxy.js', () => ({ + probeDaemon: vi.fn(), +})); + +vi.mock('./ports/port-holder.js', () => ({ + captureLookup: vi.fn(), + findPortHolder: vi.fn(), +})); + +vi.mock('@/log.js', () => ({ + log: vi.fn(), +})); + +import { + probePresence, + probePresenceWithStatus, + PortPresence, +} from '@/command/daemon/binding/port-presence.js'; +import { fetchStatus } from '@/command/daemon/binding/daemon-status-probe.js'; +import { findPortHolder } from './ports/port-holder.js'; const DAEMON_PID = 70244; const STRANGER_PID = 99001; describe('planKill', () => { - it('has nothing to do when no listener and no live recorded pid', () => { + it('has nothing to do when no listener, no recorded pid, and no daemon status', () => { expect( - planKill({ listener: null, recordedPid: null, answersStatus: false, force: false }).action, + planKill({ + listener: null, + recordedPid: null, + answersStatus: false, + force: false, + }).action, ).toBe(KillAction.NOTHING); }); @@ -97,10 +143,44 @@ describe('planKill', () => { expect(plan.identifiedListener).toBe(false); }); - it('never plans to kill without a pid', () => { - for (const force of [false, true]) { - const plan = planKill({ listener: null, recordedPid: null, answersStatus: true, force }); - expect(plan.action).not.toBe(KillAction.KILL); - } + it('refuses when /status identifies a daemon but no listener or recorded pid is available', () => { + const plan = planKill({ + listener: null, + recordedPid: null, + answersStatus: true, + force: false, + }); + + expect(plan.action).toBe(KillAction.REFUSE); + expect(plan.pid).toBeUndefined(); + }); + + it('does not report success when the killed process exits but the port is still occupied', async () => { + vi.mocked(probePresenceWithStatus).mockResolvedValueOnce({ + presence: PortPresence.DAEMON, + status: { running: true, pid: DAEMON_PID }, +}); + + vi.mocked(probePresence).mockResolvedValueOnce(PortPresence.FOREIGN); + + vi.mocked(findPortHolder).mockReturnValue({ + pid: DAEMON_PID, + command: 'node', + }); + + vi.mocked(fetchStatus).mockResolvedValue({ running: true, pid: DAEMON_PID }); + + const terminateProcess = vi.fn().mockResolvedValue({ + gone: true, + escalated: false, + }); + + const result = await runKill(4400, false, terminateProcess); + + expect(terminateProcess).toHaveBeenCalledWith(DAEMON_PID); + expect(probePresenceWithStatus).toHaveBeenCalledTimes(1); + expect(fetchStatus).toHaveBeenCalledTimes(0); + expect(probePresence).toHaveBeenCalledTimes(1); + expect(result).toBe(false); }); }); diff --git a/server/src/command/cli/cli-kill.ts b/server/src/command/cli/cli-kill.ts index 7a653abb6..631718a41 100644 --- a/server/src/command/cli/cli-kill.ts +++ b/server/src/command/cli/cli-kill.ts @@ -18,7 +18,11 @@ * user actually wants freed, and falls back to the pid file only where the lookup cannot run. */ -import { PortPresence, probePresence } from '@/command/daemon/binding/port-presence.js'; +import { + PortPresence, + probePresence, + probePresenceWithStatus, +} from '@/command/daemon/binding/port-presence.js'; import { isAlive, readPid, removePid } from '@/command/daemon/daemon.js'; import { probeDaemon } from '@/surface/mcp/mcp-proxy.js'; import { log } from '@/log.js'; @@ -62,16 +66,37 @@ export function planKill(input: { recordedPid: number | null; /** Whether the port answers `/status`, i.e. whatever holds it is a Reticle daemon. */ answersStatus: boolean; + /** Whether the user has requested to force the kill. */ force: boolean; }): KillPlan { const { listener, recordedPid, answersStatus, force } = input; if (null === listener) { - // No listener identified. On Windows and in slim containers that means the lookup could not run - // rather than that the port is free, so the recorded pid is the best target we have — and the - // report says which of the two it is rather than implying a lookup we never made. - if (null === recordedPid) - return { action: KillAction.NOTHING, forced: false, identifiedListener: false }; - return { action: KillAction.KILL, pid: recordedPid, forced: false, identifiedListener: false }; + // When the listener lookup is unavailable, /status can confirm that the port belongs to Reticle, + // but without a trusted process id there is no safe process to terminate. + if (null !== recordedPid) { + return { + action: KillAction.KILL, + pid: recordedPid, + forced: false, + identifiedListener: false, + }; + } + + // The port is known to be a Reticle daemon, but without a pid there is no safe process to kill. + if (answersStatus) { + return { + action: KillAction.REFUSE, + forced: false, + identifiedListener: false, + reason:PID_UNKNOWN_REFUSAL_REASON, + }; + } + + return { + action: KillAction.NOTHING, + forced: false, + identifiedListener: false, + }; } const ours = answersStatus || listener.pid === recordedPid; if (ours || force) { @@ -110,10 +135,14 @@ const KILL_POLL_MS = 100; * daemon that ignored SIGTERM until a human ran `kill -9` by hand, which is the moment the `lsof -ti` * pipeline gets typed and the agent's proxy dies with the daemon. */ -async function terminate(pid: number): Promise<{ gone: boolean; escalated: boolean }> { +async function terminate( + pid: number, + kill: (pid: number, signal: NodeJS.Signals) => void = (targetPid, signal) => + process.kill(targetPid, signal), +): Promise<{ gone: boolean; escalated: boolean }> { const signal = (name: NodeJS.Signals): void => { try { - process.kill(pid, name); + kill(pid, name); } catch { // Gone between the liveness check and here. The next poll reports it as gone. } @@ -145,10 +174,28 @@ async function terminate(pid: number): Promise<{ gone: boolean; escalated: boole const PROXY_SPARED_NOTE = 'only the listener was signalled. Any `reticle mcp` proxy on this port holds a client connection, ' + 'not the port, and was left alone — it goes dormant and starts a fresh daemon on the next tool call.'; +const PID_UNKNOWN_REFUSAL_REASON = + 'The port answers /status as a Reticle daemon, but Reticle could not determine its pid. ' + + 'Refusing to report success because the daemon is still running.'; + +const PID_SURVIVED_KILL_NOTE = (pid: number): string => + `pid ${String(pid)} survived SIGKILL, so it is not yours to kill (permissions) or is an ` + + `unkillable zombie. The port is still held. ${PROXY_SPARED_NOTE}`; + +const PID_EXITED_PORT_HELD_NOTE = (pid: number): string => + `pid ${String(pid)} exited, but the port is still held. ${PROXY_SPARED_NOTE}`; /** `reticle kill` — free the port. Resolves to whether the port is now free. */ -export async function runKill(port: number, force: boolean): Promise { - const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }); +export async function runKill( + port: number, + force: boolean, + terminateProcess: typeof terminate = terminate, +): Promise { + const probe = await probePresenceWithStatus(port, { + tcpOpen: probeDaemon, + status: fetchStatus, + }); + const { presence } = probe; const recorded = readPid(port); const plan = planKill({ listener: findPortHolder(port, captureLookup), @@ -172,9 +219,46 @@ export async function runKill(port: number, force: boolean): Promise { log('reticle_kill_nothing_to_do', { port, presence }); return true; } - const { gone, escalated } = await terminate(pid); - if (gone) removePid(port); - log(gone ? 'reticle_killed' : 'reticle_kill_survived', { + + const { gone, escalated } = await terminateProcess(pid); + + if (!gone) { + log('reticle_kill_survived', { + port, + pid, + escalated, + ...(plan.forced ? { forced: true } : {}), + // Say whether the process was identified by the OS as the listener or came from + // Reticle's recorded pid. + // observed listening, and those are different claims. + listenerIdentified: plan.identifiedListener, + note: PID_SURVIVED_KILL_NOTE(pid), + }); + return false; + } + + // A dead process does not by itself prove that the port is free. Another process may still + // hold the port, so verify the port state before reporting a successful kill. + const remainingPresence = await probePresence(port, { + tcpOpen: probeDaemon, + status: fetchStatus, + }); + + if (remainingPresence !== PortPresence.FREE) { + log('reticle_kill_survived', { + port, + pid, + escalated, + ...(plan.forced ? { forced: true } : {}), + listenerIdentified: plan.identifiedListener, + note: PID_EXITED_PORT_HELD_NOTE(pid), + }); + return false; + } + + removePid(port); + + log('reticle_killed', { port, pid, escalated, @@ -182,10 +266,8 @@ export async function runKill(port: number, force: boolean): Promise { // Say where the pid came from. Without `lsof` this is the pid we recorded, not the pid we // observed listening, and those are different claims. listenerIdentified: plan.identifiedListener, - note: gone - ? PROXY_SPARED_NOTE - : `pid ${String(pid)} survived SIGKILL, so it is not yours to kill (permissions) or is an ` + - `unkillable zombie. The port is still held. ${PROXY_SPARED_NOTE}`, + note: PROXY_SPARED_NOTE, }); - return gone; + + return true; } diff --git a/server/src/command/cli/ports/port-holder.test.ts b/server/src/command/cli/ports/port-holder.test.ts index fef6c222d..858e04adb 100644 --- a/server/src/command/cli/ports/port-holder.test.ts +++ b/server/src/command/cli/ports/port-holder.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest'; import { describeForeignHolder, findListenerCwd, + parseNetstatPortHolder, parsePortHolder, parseProcessCwd, } from './port-holder.js'; - /** * Naming the process that holds the port. * @@ -51,6 +51,49 @@ describe('parsePortHolder', () => { }); }); +describe('parseNetstatPortHolder', () => { + it('reads the pid of a TCP listener for the requested port', () => { + const stdout = + ' TCP 127.0.0.1:4400 0.0.0.0:0 LISTENING 90502\n'; + + expect(parseNetstatPortHolder(stdout, 4400)).toEqual({ + pid: 90502, + command: 'unknown', + }); + }); + + it('matches IPv6 listeners', () => { + const stdout = + ' TCP [::1]:4400 [::]:0 LISTENING 90777\n'; + + expect(parseNetstatPortHolder(stdout, 4400)).toEqual({ + pid: 90777, + command: 'unknown', + }); + }); + + it('ignores listeners on other ports', () => { + const stdout = + ' TCP 127.0.0.1:4500 0.0.0.0:0 LISTENING 90502\n'; + + expect(parseNetstatPortHolder(stdout, 4400)).toBeNull(); + }); + + it('ignores non-listening connections', () => { + const stdout = + ' TCP 127.0.0.1:4400 127.0.0.1:5500 ESTABLISHED 90502\n'; + + expect(parseNetstatPortHolder(stdout, 4400)).toBeNull(); + }); + + it('returns null when the listener pid is invalid', () => { + const stdout = + ' TCP 127.0.0.1:4400 0.0.0.0:0 LISTENING not-a-pid\n'; + + expect(parseNetstatPortHolder(stdout, 4400)).toBeNull(); + }); +}); + describe('describeForeignHolder', () => { it('names the pid and command, and does not suggest the command that kills the agent', () => { const text = describeForeignHolder(4400, { pid: 90502, command: 'node' }); diff --git a/server/src/command/cli/ports/port-holder.ts b/server/src/command/cli/ports/port-holder.ts index ba2b0d14d..468d436f1 100644 --- a/server/src/command/cli/ports/port-holder.ts +++ b/server/src/command/cli/ports/port-holder.ts @@ -129,6 +129,38 @@ export function describeForeignHolder( ); } +/** Parse the first TCP listener for a port from `netstat -ano` output. */ +export function parseNetstatPortHolder(stdout: string, port: number): PortHolder | null { + for (const raw of stdout.split('\n')) { + const fields = raw.trim().split(/\s+/); + const protocol = fields[0]; + const localAddress = fields[1]; + const state = fields[3]; + const pidValue = fields[4]; + + if ( + undefined === protocol || + undefined === localAddress || + undefined === state || + undefined === pidValue + ) { + continue; + } + + if ('TCP' !== protocol.toUpperCase() || 'LISTENING' !== state.toUpperCase()) continue; + + const portSuffix = `:${String(port)}`; + if (!localAddress.endsWith(portSuffix)) continue; + + const pid = Number(pidValue); + if (!Number.isInteger(pid) || pid <= 0) return null; + + return { pid, command: 'unknown' }; + } + + return null; +} + /** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */ export function findPortHolder( port: number, @@ -138,7 +170,12 @@ export function findPortHolder( // that matters: without it this also reports the agent's own MCP proxy, which is a client of the // port, not its owner. const out = exec('lsof', ['-nP', `-iTCP:${String(port)}`, '-sTCP:LISTEN', '-F', 'pc']); - return null === out ? null : parsePortHolder(out); + if (null !== out) return parsePortHolder(out); + + // Windows does not normally have lsof. netstat is built into Windows and reports the owning PID + // for listening TCP sockets, giving kill a process identity tied to the actual port owner. + const netstat = exec('netstat', ['-ano', '-p', 'tcp']); + return null === netstat ? null : parseNetstatPortHolder(netstat, port); } /** `lsof -Fn` emits the file name on its own line, prefixed `n`. */ diff --git a/server/src/command/daemon/binding/port-presence.ts b/server/src/command/daemon/binding/port-presence.ts index 0349a23fe..7fca24546 100644 --- a/server/src/command/daemon/binding/port-presence.ts +++ b/server/src/command/daemon/binding/port-presence.ts @@ -86,6 +86,26 @@ export async function probePresence( return classifyPort({ tcpOpen, statusAnswered: status !== undefined }); } +export async function probePresenceWithStatus( + port: number, + probes: { + tcpOpen: (port: number) => Promise; + status: (port: number) => Promise; + }, +): Promise<{ presence: PortPresence; status: unknown }> { + const tcpOpen = await probes.tcpOpen(port); + if (!tcpOpen) { + return { presence: PortPresence.FREE, status: undefined }; + } + + const status = await probes.status(port); + + return { + presence: classifyPort({ tcpOpen, statusAnswered: status !== undefined }), + status, + }; +} + /** * Who is holding the port, when we can tell. * From 39c2d8597ae7517b403774574a590cd4455cb571 Mon Sep 17 00:00:00 2001 From: SmashRex Date: Sat, 3 Oct 2026 18:39:09 +0100 Subject: [PATCH 2/8] fix: resolve CI syntax and formatting issues Signed-off-by: SmashRex --- server/src/command/cli/cli-kill.test.ts | 6 +++--- server/src/command/cli/cli-kill.ts | 2 +- server/src/command/cli/ports/port-holder.test.ts | 15 +++++---------- server/src/index.ts | 3 +++ 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/server/src/command/cli/cli-kill.test.ts b/server/src/command/cli/cli-kill.test.ts index 46f413571..09917d5ad 100644 --- a/server/src/command/cli/cli-kill.test.ts +++ b/server/src/command/cli/cli-kill.test.ts @@ -157,9 +157,9 @@ describe('planKill', () => { it('does not report success when the killed process exits but the port is still occupied', async () => { vi.mocked(probePresenceWithStatus).mockResolvedValueOnce({ - presence: PortPresence.DAEMON, - status: { running: true, pid: DAEMON_PID }, -}); + presence: PortPresence.DAEMON, + status: { running: true, pid: DAEMON_PID }, + }); vi.mocked(probePresence).mockResolvedValueOnce(PortPresence.FOREIGN); diff --git a/server/src/command/cli/cli-kill.ts b/server/src/command/cli/cli-kill.ts index 631718a41..81d6b6652 100644 --- a/server/src/command/cli/cli-kill.ts +++ b/server/src/command/cli/cli-kill.ts @@ -88,7 +88,7 @@ export function planKill(input: { action: KillAction.REFUSE, forced: false, identifiedListener: false, - reason:PID_UNKNOWN_REFUSAL_REASON, + reason: PID_UNKNOWN_REFUSAL_REASON, }; } diff --git a/server/src/command/cli/ports/port-holder.test.ts b/server/src/command/cli/ports/port-holder.test.ts index 858e04adb..8d0e71882 100644 --- a/server/src/command/cli/ports/port-holder.test.ts +++ b/server/src/command/cli/ports/port-holder.test.ts @@ -53,8 +53,7 @@ describe('parsePortHolder', () => { describe('parseNetstatPortHolder', () => { it('reads the pid of a TCP listener for the requested port', () => { - const stdout = - ' TCP 127.0.0.1:4400 0.0.0.0:0 LISTENING 90502\n'; + const stdout = ' TCP 127.0.0.1:4400 0.0.0.0:0 LISTENING 90502\n'; expect(parseNetstatPortHolder(stdout, 4400)).toEqual({ pid: 90502, @@ -63,8 +62,7 @@ describe('parseNetstatPortHolder', () => { }); it('matches IPv6 listeners', () => { - const stdout = - ' TCP [::1]:4400 [::]:0 LISTENING 90777\n'; + const stdout = ' TCP [::1]:4400 [::]:0 LISTENING 90777\n'; expect(parseNetstatPortHolder(stdout, 4400)).toEqual({ pid: 90777, @@ -73,22 +71,19 @@ describe('parseNetstatPortHolder', () => { }); it('ignores listeners on other ports', () => { - const stdout = - ' TCP 127.0.0.1:4500 0.0.0.0:0 LISTENING 90502\n'; + const stdout = ' TCP 127.0.0.1:4500 0.0.0.0:0 LISTENING 90502\n'; expect(parseNetstatPortHolder(stdout, 4400)).toBeNull(); }); it('ignores non-listening connections', () => { - const stdout = - ' TCP 127.0.0.1:4400 127.0.0.1:5500 ESTABLISHED 90502\n'; + const stdout = ' TCP 127.0.0.1:4400 127.0.0.1:5500 ESTABLISHED 90502\n'; expect(parseNetstatPortHolder(stdout, 4400)).toBeNull(); }); it('returns null when the listener pid is invalid', () => { - const stdout = - ' TCP 127.0.0.1:4400 0.0.0.0:0 LISTENING not-a-pid\n'; + const stdout = ' TCP 127.0.0.1:4400 0.0.0.0:0 LISTENING not-a-pid\n'; expect(parseNetstatPortHolder(stdout, 4400)).toBeNull(); }); diff --git a/server/src/index.ts b/server/src/index.ts index 45ccf8a62..82ce85d57 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -406,6 +406,8 @@ export async function startDaemon(options: StartOptions = {}): Promise Date: Fri, 9 Oct 2026 11:11:29 +0100 Subject: [PATCH 3/8] fix: finish Windows daemon kill review cleanup Signed-off-by: SmashRex --- server/src/command/cli/cli-kill.test.ts | 27 ++++++++------- server/src/command/cli/cli-kill.ts | 17 +++------- server/src/command/cli/ports/port-holder.ts | 33 +++---------------- .../command/daemon/binding/port-presence.ts | 20 ----------- server/src/index.ts | 4 +-- 5 files changed, 24 insertions(+), 77 deletions(-) diff --git a/server/src/command/cli/cli-kill.test.ts b/server/src/command/cli/cli-kill.test.ts index 09917d5ad..785084833 100644 --- a/server/src/command/cli/cli-kill.test.ts +++ b/server/src/command/cli/cli-kill.test.ts @@ -28,7 +28,6 @@ vi.mock('@/command/daemon/binding/port-presence.js', () => ({ FREE: 'free', }, probePresence: vi.fn(), - probePresenceWithStatus: vi.fn(), })); vi.mock('@/command/daemon/binding/daemon-status-probe.js', () => ({ @@ -48,11 +47,7 @@ vi.mock('@/log.js', () => ({ log: vi.fn(), })); -import { - probePresence, - probePresenceWithStatus, - PortPresence, -} from '@/command/daemon/binding/port-presence.js'; +import { probePresence, PortPresence } from '@/command/daemon/binding/port-presence.js'; import { fetchStatus } from '@/command/daemon/binding/daemon-status-probe.js'; import { findPortHolder } from './ports/port-holder.js'; @@ -71,6 +66,18 @@ describe('planKill', () => { ).toBe(KillAction.NOTHING); }); + it.each([false, true])('never plans to kill without a pid (force: %s)', (force) => { + const plan = planKill({ + listener: null, + recordedPid: null, + answersStatus: true, + force, + }); + + expect(plan.action).not.toBe(KillAction.KILL); + expect(plan.pid).toBeUndefined(); + }); + it('kills the listener when it is the pid we recorded for the port', () => { const plan = planKill({ listener: { pid: DAEMON_PID, command: 'node' }, @@ -156,10 +163,7 @@ describe('planKill', () => { }); it('does not report success when the killed process exits but the port is still occupied', async () => { - vi.mocked(probePresenceWithStatus).mockResolvedValueOnce({ - presence: PortPresence.DAEMON, - status: { running: true, pid: DAEMON_PID }, - }); + vi.mocked(probePresence).mockResolvedValueOnce(PortPresence.DAEMON); vi.mocked(probePresence).mockResolvedValueOnce(PortPresence.FOREIGN); @@ -178,9 +182,8 @@ describe('planKill', () => { const result = await runKill(4400, false, terminateProcess); expect(terminateProcess).toHaveBeenCalledWith(DAEMON_PID); - expect(probePresenceWithStatus).toHaveBeenCalledTimes(1); expect(fetchStatus).toHaveBeenCalledTimes(0); - expect(probePresence).toHaveBeenCalledTimes(1); + expect(probePresence).toHaveBeenCalledTimes(2); expect(result).toBe(false); }); }); diff --git a/server/src/command/cli/cli-kill.ts b/server/src/command/cli/cli-kill.ts index 81d6b6652..ebe7cad70 100644 --- a/server/src/command/cli/cli-kill.ts +++ b/server/src/command/cli/cli-kill.ts @@ -18,11 +18,7 @@ * user actually wants freed, and falls back to the pid file only where the lookup cannot run. */ -import { - PortPresence, - probePresence, - probePresenceWithStatus, -} from '@/command/daemon/binding/port-presence.js'; +import { PortPresence, probePresence } from '@/command/daemon/binding/port-presence.js'; import { isAlive, readPid, removePid } from '@/command/daemon/daemon.js'; import { probeDaemon } from '@/surface/mcp/mcp-proxy.js'; import { log } from '@/log.js'; @@ -135,14 +131,10 @@ const KILL_POLL_MS = 100; * daemon that ignored SIGTERM until a human ran `kill -9` by hand, which is the moment the `lsof -ti` * pipeline gets typed and the agent's proxy dies with the daemon. */ -async function terminate( - pid: number, - kill: (pid: number, signal: NodeJS.Signals) => void = (targetPid, signal) => - process.kill(targetPid, signal), -): Promise<{ gone: boolean; escalated: boolean }> { +async function terminate(pid: number): Promise<{ gone: boolean; escalated: boolean }> { const signal = (name: NodeJS.Signals): void => { try { - kill(pid, name); + process.kill(pid, name); } catch { // Gone between the liveness check and here. The next poll reports it as gone. } @@ -191,11 +183,10 @@ export async function runKill( force: boolean, terminateProcess: typeof terminate = terminate, ): Promise { - const probe = await probePresenceWithStatus(port, { + const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus, }); - const { presence } = probe; const recorded = readPid(port); const plan = planKill({ listener: findPortHolder(port, captureLookup), diff --git a/server/src/command/cli/ports/port-holder.ts b/server/src/command/cli/ports/port-holder.ts index 468d436f1..6769d0ba4 100644 --- a/server/src/command/cli/ports/port-holder.ts +++ b/server/src/command/cli/ports/port-holder.ts @@ -15,6 +15,7 @@ */ import { spawnSync } from 'node:child_process'; +import { parseNetstatListeners } from '../../setup/terminal/listeners.js'; export interface PortHolder { pid: number; @@ -129,36 +130,10 @@ export function describeForeignHolder( ); } -/** Parse the first TCP listener for a port from `netstat -ano` output. */ +/** Find the first TCP listener for a port in `netstat -ano` output. */ export function parseNetstatPortHolder(stdout: string, port: number): PortHolder | null { - for (const raw of stdout.split('\n')) { - const fields = raw.trim().split(/\s+/); - const protocol = fields[0]; - const localAddress = fields[1]; - const state = fields[3]; - const pidValue = fields[4]; - - if ( - undefined === protocol || - undefined === localAddress || - undefined === state || - undefined === pidValue - ) { - continue; - } - - if ('TCP' !== protocol.toUpperCase() || 'LISTENING' !== state.toUpperCase()) continue; - - const portSuffix = `:${String(port)}`; - if (!localAddress.endsWith(portSuffix)) continue; - - const pid = Number(pidValue); - if (!Number.isInteger(pid) || pid <= 0) return null; - - return { pid, command: 'unknown' }; - } - - return null; + const listener = parseNetstatListeners(stdout).find((entry) => entry.port === port); + return listener === undefined ? null : { pid: listener.pid, command: 'unknown' }; } /** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */ diff --git a/server/src/command/daemon/binding/port-presence.ts b/server/src/command/daemon/binding/port-presence.ts index 7fca24546..0349a23fe 100644 --- a/server/src/command/daemon/binding/port-presence.ts +++ b/server/src/command/daemon/binding/port-presence.ts @@ -86,26 +86,6 @@ export async function probePresence( return classifyPort({ tcpOpen, statusAnswered: status !== undefined }); } -export async function probePresenceWithStatus( - port: number, - probes: { - tcpOpen: (port: number) => Promise; - status: (port: number) => Promise; - }, -): Promise<{ presence: PortPresence; status: unknown }> { - const tcpOpen = await probes.tcpOpen(port); - if (!tcpOpen) { - return { presence: PortPresence.FREE, status: undefined }; - } - - const status = await probes.status(port); - - return { - presence: classifyPort({ tcpOpen, statusAnswered: status !== undefined }), - status, - }; -} - /** * Who is holding the port, when we can tell. * diff --git a/server/src/index.ts b/server/src/index.ts index 82ce85d57..dda84264f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -405,9 +405,8 @@ export async function startDaemon(options: StartOptions = {}): Promise Date: Tue, 13 Oct 2026 14:59:14 +0100 Subject: [PATCH 4/8] fix: remove forbidden ports terminal dependency Signed-off-by: SmashRex --- server/src/command/cli/ports/port-holder.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/server/src/command/cli/ports/port-holder.ts b/server/src/command/cli/ports/port-holder.ts index 6769d0ba4..6fb5533f0 100644 --- a/server/src/command/cli/ports/port-holder.ts +++ b/server/src/command/cli/ports/port-holder.ts @@ -15,7 +15,6 @@ */ import { spawnSync } from 'node:child_process'; -import { parseNetstatListeners } from '../../setup/terminal/listeners.js'; export interface PortHolder { pid: number; @@ -132,8 +131,23 @@ export function describeForeignHolder( /** Find the first TCP listener for a port in `netstat -ano` output. */ export function parseNetstatPortHolder(stdout: string, port: number): PortHolder | null { - const listener = parseNetstatListeners(stdout).find((entry) => entry.port === port); - return listener === undefined ? null : { pid: listener.pid, command: 'unknown' }; + for (const line of stdout.split(/\r?\n/)) { + const fields = line.trim().split(/\s+/); + if (fields.length < 5) continue; + if (fields[0]?.toUpperCase() !== 'TCP') continue; + if (fields[3]?.toUpperCase() !== 'LISTENING') continue; + + const localAddress = fields[1] ?? ''; + const localPort = Number(localAddress.slice(localAddress.lastIndexOf(':') + 1)); + const pid = Number(fields[4]); + + if (localPort !== port) continue; + if (!Number.isInteger(pid) || pid <= 0) continue; + + return { pid, command: 'unknown' }; + } + + return null; } /** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */ From cc79f57c784024d02e92668c13b8bf252a361695 Mon Sep 17 00:00:00 2001 From: SmashRex Date: Thu, 15 Oct 2026 00:02:19 +0100 Subject: [PATCH 5/8] fix: share Windows netstat listener parsing Signed-off-by: SmashRex --- .changes/1306-windows-daemon-kill.md | 3 ++ server/src/command/cli/ports/port-holder.ts | 22 ++--------- server/src/command/netstat/listeners.ts | 37 +++++++++++++++++++ .../src/command/setup/terminal/listeners.ts | 32 +--------------- server/src/directory-reach.test.ts | 6 ++- 5 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 .changes/1306-windows-daemon-kill.md create mode 100644 server/src/command/netstat/listeners.ts diff --git a/.changes/1306-windows-daemon-kill.md b/.changes/1306-windows-daemon-kill.md new file mode 100644 index 000000000..f63af1385 --- /dev/null +++ b/.changes/1306-windows-daemon-kill.md @@ -0,0 +1,3 @@ +### Fixed + +- **`@reticlehq/server` � `reticle kill` could fail to identify a daemon holding a port on Windows.** Port detection now handles IPv4 and IPv6 listener output from `netstat`, helping Reticle identify the process to terminate. diff --git a/server/src/command/cli/ports/port-holder.ts b/server/src/command/cli/ports/port-holder.ts index 6fb5533f0..9bac22c79 100644 --- a/server/src/command/cli/ports/port-holder.ts +++ b/server/src/command/cli/ports/port-holder.ts @@ -15,6 +15,7 @@ */ import { spawnSync } from 'node:child_process'; +import { parseNetstatListeners } from '../../netstat/listeners.js'; export interface PortHolder { pid: number; @@ -131,23 +132,8 @@ export function describeForeignHolder( /** Find the first TCP listener for a port in `netstat -ano` output. */ export function parseNetstatPortHolder(stdout: string, port: number): PortHolder | null { - for (const line of stdout.split(/\r?\n/)) { - const fields = line.trim().split(/\s+/); - if (fields.length < 5) continue; - if (fields[0]?.toUpperCase() !== 'TCP') continue; - if (fields[3]?.toUpperCase() !== 'LISTENING') continue; - - const localAddress = fields[1] ?? ''; - const localPort = Number(localAddress.slice(localAddress.lastIndexOf(':') + 1)); - const pid = Number(fields[4]); - - if (localPort !== port) continue; - if (!Number.isInteger(pid) || pid <= 0) continue; - - return { pid, command: 'unknown' }; - } - - return null; + const listener = parseNetstatListeners(stdout).find((row) => row.port === port); + return listener === undefined ? null : { pid: listener.pid, command: 'unknown' }; } /** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */ @@ -163,7 +149,7 @@ export function findPortHolder( // Windows does not normally have lsof. netstat is built into Windows and reports the owning PID // for listening TCP sockets, giving kill a process identity tied to the actual port owner. - const netstat = exec('netstat', ['-ano', '-p', 'tcp']); + const netstat = exec('netstat', ['-ano']); return null === netstat ? null : parseNetstatPortHolder(netstat, port); } diff --git a/server/src/command/netstat/listeners.ts b/server/src/command/netstat/listeners.ts new file mode 100644 index 000000000..d8b9dddee --- /dev/null +++ b/server/src/command/netstat/listeners.ts @@ -0,0 +1,37 @@ +/** One TCP listener and the process that owns it. */ +export interface NetstatListener { + readonly port: number; + readonly pid: number; +} + +const NETSTAT_PROTO = 'TCP'; +const NETSTAT_STATE = 'LISTENING'; +const NETSTAT_MIN_COLUMNS = 5; + +/** + * Parse Windows `netstat -ano` output. + * + * Read the port after the LAST colon so IPv6 addresses such as `[::]:3000` + * are handled correctly. Do not restrict netstat to IPv4 with `-p tcp`. + */ +export function parseNetstatListeners(out: string): NetstatListener[] { + const rows: NetstatListener[] = []; + + for (const line of String(out).split(/\r?\n/)) { + const fields = line.trim().split(/\s+/); + if (fields.length < NETSTAT_MIN_COLUMNS) continue; + if (fields[0]?.toUpperCase() !== NETSTAT_PROTO || fields[3]?.toUpperCase() !== NETSTAT_STATE) { + continue; + } + + const localAddress = fields[1] ?? ''; + const port = Number(localAddress.slice(localAddress.lastIndexOf(':') + 1)); + const pid = Number(fields[4]); + + if (Number.isInteger(port) && port > 0 && Number.isInteger(pid) && pid > 0) { + rows.push({ port, pid }); + } + } + + return rows; +} diff --git a/server/src/command/setup/terminal/listeners.ts b/server/src/command/setup/terminal/listeners.ts index 4c2b06633..23f85d62b 100644 --- a/server/src/command/setup/terminal/listeners.ts +++ b/server/src/command/setup/terminal/listeners.ts @@ -1,3 +1,5 @@ +export { parseNetstatListeners } from '../../netstat/listeners.js'; + /** * Which ports a process tree is listening on, parsed from the tools that can answer that. * @@ -8,12 +10,6 @@ * platform's most common React setup. */ -/** One LISTENING socket and the process that owns it. */ -interface Listener { - readonly port: number; - readonly pid: number; -} - /** `lsof -a -p -iTCP -sTCP:LISTEN -P -n`, NAME column only. */ export function parseLsofPorts(out: string): number[] { const ports = new Set(); @@ -21,30 +17,6 @@ export function parseLsofPorts(out: string): number[] { return [...ports]; } -const NETSTAT_PROTO = 'TCP'; -const NETSTAT_STATE = 'LISTENING'; -const NETSTAT_MIN_COLUMNS = 5; - -/** - * `netstat -ano` on Windows. - * - * The port is whatever follows the LAST colon: `[::]:3000` has three of them, so splitting on ':' - * and taking index 1 reads every IPv6 row as port 0 and silently drops half the table. - */ -export function parseNetstatListeners(out: string): Listener[] { - const rows: Listener[] = []; - for (const line of String(out).split(/\r?\n/)) { - const f = line.trim().split(/\s+/); - if (f.length < NETSTAT_MIN_COLUMNS) continue; - if (f[0]?.toUpperCase() !== NETSTAT_PROTO || f[3]?.toUpperCase() !== NETSTAT_STATE) continue; - const local = f[1] ?? ''; - const port = Number(local.slice(local.lastIndexOf(':') + 1)); - const pid = Number(f[4]); - if (Number.isInteger(port) && port > 0 && Number.isInteger(pid)) rows.push({ port, pid }); - } - return rows; -} - /** * One (pid, parent) pair, as `wmic process get CreationDate,ParentProcessId,ProcessId` prints them. * diff --git a/server/src/directory-reach.test.ts b/server/src/directory-reach.test.ts index 84fe9b214..21d3f776f 100644 --- a/server/src/directory-reach.test.ts +++ b/server/src/directory-reach.test.ts @@ -280,7 +280,7 @@ const REACHES_FOR: Record = { * parts of a program ask is a thing, and it was seven files in the middle of the CLI. It * reaches for nothing itself. */ - ports: [], + ports: ['netstat'], /** * Talking to the person at the terminal: asking, interrupting, showing progress, recording what * was said. Named `terminal` and not `console`, which in a JavaScript codebase means the API. @@ -288,7 +288,9 @@ const REACHES_FOR: Record = { * It reaches for four things and that is honest -- it prints daemon state, port state and * telemetry notices, because that is what a setup transcript is made of. */ - terminal: ['binding', 'daemon', 'launch', 'resolve', 'telemetry'], + terminal: ['binding', 'daemon', 'launch', 'netstat', 'resolve', 'telemetry'], + /** Shared Windows netstat listener parsing. */ + netstat: [], /** A recorded flow and what became of it: the tape, the rewind, the flake, the halt. */ recording: [], /** What a human wrote on a step, and where they pointed when they wrote it. */ From 2964adfbcc0b15aa10bf0c62aafb790807e604e6 Mon Sep 17 00:00:00 2001 From: SmashRex Date: Thu, 15 Oct 2026 07:04:12 +0100 Subject: [PATCH 6/8] style: format Windows netstat listener parser Signed-off-by: SmashRex --- server/src/command/netstat/listeners.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/command/netstat/listeners.ts b/server/src/command/netstat/listeners.ts index d8b9dddee..dd00c62b9 100644 --- a/server/src/command/netstat/listeners.ts +++ b/server/src/command/netstat/listeners.ts @@ -28,7 +28,7 @@ export function parseNetstatListeners(out: string): NetstatListener[] { const port = Number(localAddress.slice(localAddress.lastIndexOf(':') + 1)); const pid = Number(fields[4]); - if (Number.isInteger(port) && port > 0 && Number.isInteger(pid) && pid > 0) { + if (Number.isInteger(port) && port > 0 && Number.isInteger(pid) && pid > 0) { rows.push({ port, pid }); } } From b2402b46c81e6acbeaf459d9d859be2944cfb2e2 Mon Sep 17 00:00:00 2001 From: SmashRex Date: Thu, 15 Oct 2026 13:21:21 +0100 Subject: [PATCH 7/8] fix: clarify Windows daemon kill comment Signed-off-by: SmashRex --- server/src/command/cli/cli-kill.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/command/cli/cli-kill.ts b/server/src/command/cli/cli-kill.ts index ebe7cad70..bf5b9cbd0 100644 --- a/server/src/command/cli/cli-kill.ts +++ b/server/src/command/cli/cli-kill.ts @@ -219,9 +219,7 @@ export async function runKill( pid, escalated, ...(plan.forced ? { forced: true } : {}), - // Say whether the process was identified by the OS as the listener or came from - // Reticle's recorded pid. - // observed listening, and those are different claims. + // A recorded PID is not necessarily the process observed listening on the port. listenerIdentified: plan.identifiedListener, note: PID_SURVIVED_KILL_NOTE(pid), }); From 5e34277913bf0e2ac6c756da0ca308bfcbd380bd Mon Sep 17 00:00:00 2001 From: SmashRex Date: Thu, 15 Oct 2026 14:29:41 +0100 Subject: [PATCH 8/8] style: format Windows daemon kill comment Signed-off-by: SmashRex --- server/src/command/cli/cli-kill.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/command/cli/cli-kill.ts b/server/src/command/cli/cli-kill.ts index bf5b9cbd0..0c5a2d9f6 100644 --- a/server/src/command/cli/cli-kill.ts +++ b/server/src/command/cli/cli-kill.ts @@ -219,7 +219,7 @@ export async function runKill( pid, escalated, ...(plan.forced ? { forced: true } : {}), - // A recorded PID is not necessarily the process observed listening on the port. + // A recorded PID is not necessarily the process observed listening on the port. listenerIdentified: plan.identifiedListener, note: PID_SURVIVED_KILL_NOTE(pid), });