22 * Reclaim a listen port after stop/update so restart can stay on the configured
33 * port instead of hopping to an ephemeral one (Windows CLOSE_WAIT / leftover ocx).
44 *
5- * Killing is never the default: a process may be killed only when the caller
6- * supplies a non-empty explicit PID allowlist for a process it just stopped.
5+ * Killing is never the default. A process may be killed only when the caller
6+ * sets `killOcxHolders` and either supplies a non-empty `onlyKillPids` allowlist
7+ * (trusted teardown PIDs, including allowlisted holders that fail ocx revalidate)
8+ * or enables `killAllOcxOnPort` for revalidated ocx listeners. Unknown foreign
9+ * (non-ocx, non-allowlisted) processes are never killed.
710 */
811import { execFileSync } from "node:child_process" ;
912import { verifyPidIdentity } from "../config" ;
@@ -19,13 +22,24 @@ export type ReclaimListenPortOptions = WaitForPortOptions & {
1922 /**
2023 * When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be
2124 * killed after revalidation. Default false — never kill without an allowlist.
25+ * When {@link killAllOcxOnPort} is also true, any ocx listener on this port
26+ * may be killed even if it is not in `onlyKillPids`.
2227 */
2328 killOcxHolders ?: boolean ;
2429 /**
2530 * Explicit PIDs the caller just stopped / hard-killed. An omitted or empty
26- * list means no process may be killed.
31+ * list means no process may be killed — unless { @link killAllOcxOnPort} is set .
2732 */
2833 onlyKillPids ?: number [ ] ;
34+ /**
35+ * When true with `killOcxHolders`, every live ocx listener on this port may be
36+ * killed (re-checked each scan). Used by post-update restart so a Windows
37+ * service wrapper that respawns a *new* bun PID mid-reclaim cannot stay
38+ * protected just because it was absent from the pre-wait allowlist snapshot.
39+ * Never kills foreign (non-ocx) processes — only allowlisted teardown PIDs
40+ * and revalidated ocx listeners.
41+ */
42+ killAllOcxOnPort ?: boolean ;
2943 /**
3044 * On Windows, force-delete IPv4 TCP rows for this local port via SetTcpEntry.
3145 * Default true on win32. Never kills foreign processes, never runs while a
@@ -157,9 +171,9 @@ export function listListenPids(port: number): number[] {
157171
158172/**
159173 * Wait until `port` can bind.
160- * Never kills a process unless `killOcxHolders === true` and `onlyKillPids` is a
161- * non-empty allowlist of PIDs the caller itself just stopped — then revalidates
162- * immediately before each kill.
174+ * Never kills a process unless `killOcxHolders === true` and either
175+ * `onlyKillPids` is a non-empty allowlist or `killAllOcxOnPort` is set — then
176+ * revalidates immediately before each kill.
163177 * Never kills foreign processes. Never drops TCP rows while a live foreign or
164178 * protected ocx listener owns the port, or when the listener scan failed.
165179 */
@@ -174,7 +188,9 @@ export async function reclaimListenPort(
174188 const allowedKillPids = new Set (
175189 ( opts . onlyKillPids ?? [ ] ) . filter ( pid => Number . isSafeInteger ( pid ) && pid > 0 ) ,
176190 ) ;
177- const mayKill = opts . killOcxHolders === true && allowedKillPids . size > 0 ;
191+ const killAllOcx = opts . killAllOcxOnPort === true ;
192+ const mayKill = opts . killOcxHolders === true
193+ && ( allowedKillPids . size > 0 || killAllOcx ) ;
178194 const dropTcpRows = opts . dropTcpRows ?? process . platform === "win32" ;
179195 const listFn = opts . listListenPidsFn ?? scanListenPids ;
180196 const isAliveFn = opts . isAliveFn ?? isProcessAlive ;
@@ -207,20 +223,44 @@ export async function reclaimListenPort(
207223
208224 for ( const pid of scan . pids ) {
209225 if ( pid === process . pid ) continue ;
210- if ( ! isAliveFn ( pid ) ) continue ; // Windows may still list a dead owner briefly
226+ if ( ! isAliveFn ( pid ) ) {
227+ // Clear "already tried" so a later respawn that reuses this PID slot
228+ // is not skipped (Windows service :loop / npm rename respawns).
229+ killed . delete ( pid ) ;
230+ continue ; // Windows may still list a dead owner briefly
231+ }
211232 const isOcx = verifyOcxFn ( pid ) === pid ;
233+ const allowlisted = allowedKillPids . has ( pid ) ;
234+ // Pre-update PIDs can fail verify while still LISTENing (dead owner still
235+ // listed, or cmdline probe raced). Allowlisted teardown PIDs may be killed;
236+ // unknown foreign claimants must remain fail-closed.
212237 if ( ! isOcx ) {
238+ if ( mayKill && allowlisted ) {
239+ if ( ! killed . has ( pid ) ) {
240+ try {
241+ killFn ( pid ) ;
242+ killed . add ( pid ) ;
243+ } catch {
244+ // Kill failed: never SetTcpEntry while the process may still own the port.
245+ protectedOcxListener = true ;
246+ }
247+ }
248+ if ( ! isAliveFn ( pid ) ) killed . delete ( pid ) ;
249+ else protectedOcxListener = true ;
250+ continue ;
251+ }
213252 foreignLive = true ;
214253 continue ;
215254 }
216- if ( ! mayKill || ! allowedKillPids . has ( pid ) ) {
255+ const mayKillThis = allowlisted || killAllOcx ;
256+ if ( ! mayKill || ! mayKillThis ) {
217257 // Healthy / intentional ocx proxy — never steal its port.
218258 protectedOcxListener = true ;
219259 continue ;
220260 }
221261 if ( ! killed . has ( pid ) ) {
222262 // Revalidate immediately before termination.
223- if ( isAliveFn ( pid ) && verifyOcxFn ( pid ) === pid && allowedKillPids . has ( pid ) ) {
263+ if ( isAliveFn ( pid ) && verifyOcxFn ( pid ) === pid && mayKillThis ) {
224264 try {
225265 killFn ( pid ) ;
226266 killed . add ( pid ) ;
@@ -233,8 +273,14 @@ export async function reclaimListenPort(
233273 protectedOcxListener = true ;
234274 }
235275 }
236- // Only reset TCP rows after confirmed process death.
237- if ( isAliveFn ( pid ) ) protectedOcxListener = true ;
276+ // Respawning supervisors (Windows service :loop) mint a new PID after each
277+ // kill — clear the per-PID "already tried" bit once the process is gone so a
278+ // later child with a reused slot is not skipped, and keep reclaiming while live.
279+ if ( ! isAliveFn ( pid ) ) {
280+ killed . delete ( pid ) ;
281+ } else {
282+ protectedOcxListener = true ;
283+ }
238284 }
239285
240286 if ( foreignLive || protectedOcxListener ) {
0 commit comments