11import { execFileSync } from "node:child_process" ;
22import { resolveLsofCommandSync } from "../infra/ports-lsof.js" ;
3+ import { tryListenOnPort } from "../infra/ports-probe.js" ;
34import { sleep } from "../utils.js" ;
45
56export type PortProcess = { pid : number ; command ?: string } ;
@@ -10,6 +11,132 @@ export type ForceFreePortResult = {
1011 escalatedToSigkill : boolean ;
1112} ;
1213
14+ type ExecFileError = NodeJS . ErrnoException & {
15+ status ?: number | null ;
16+ stderr ?: string | Buffer ;
17+ stdout ?: string | Buffer ;
18+ cause ?: unknown ;
19+ } ;
20+
21+ const FUSER_SIGNALS : Record < "SIGTERM" | "SIGKILL" , string > = {
22+ SIGTERM : "TERM" ,
23+ SIGKILL : "KILL" ,
24+ } ;
25+
26+ function readExecOutput ( value : string | Buffer | undefined ) : string {
27+ if ( typeof value === "string" ) {
28+ return value ;
29+ }
30+ if ( value instanceof Buffer ) {
31+ return value . toString ( "utf8" ) ;
32+ }
33+ return "" ;
34+ }
35+
36+ function withErrnoCode ( message : string , code : string , cause : unknown ) : Error {
37+ const out = new Error ( message , { cause : cause instanceof Error ? cause : undefined } ) as Error &
38+ NodeJS . ErrnoException ;
39+ out . code = code ;
40+ return out ;
41+ }
42+
43+ function getErrnoCode ( err : unknown ) : string | undefined {
44+ if ( ! err || typeof err !== "object" ) {
45+ return undefined ;
46+ }
47+ const direct = ( err as { code ?: unknown } ) . code ;
48+ if ( typeof direct === "string" && direct . length > 0 ) {
49+ return direct ;
50+ }
51+ const cause = ( err as { cause ?: unknown } ) . cause ;
52+ if ( cause && typeof cause === "object" ) {
53+ const nested = ( cause as { code ?: unknown } ) . code ;
54+ if ( typeof nested === "string" && nested . length > 0 ) {
55+ return nested ;
56+ }
57+ }
58+ return undefined ;
59+ }
60+
61+ function isRecoverableLsofError ( err : unknown ) : boolean {
62+ const code = getErrnoCode ( err ) ;
63+ if ( code === "ENOENT" || code === "EACCES" || code === "EPERM" ) {
64+ return true ;
65+ }
66+ const message = err instanceof Error ? err . message : String ( err ) ;
67+ return / l s o f .* ( p e r m i s s i o n d e n i e d | n o t p e r m i t t e d | o p e r a t i o n n o t p e r m i t t e d | e a c c e s | e p e r m ) / i. test (
68+ message ,
69+ ) ;
70+ }
71+
72+ function parseFuserPidList ( output : string ) : number [ ] {
73+ if ( ! output ) {
74+ return [ ] ;
75+ }
76+ const values = new Set < number > ( ) ;
77+ for ( const rawLine of output . split ( / \r ? \n / ) ) {
78+ const line = rawLine . trim ( ) ;
79+ if ( ! line ) {
80+ continue ;
81+ }
82+ const pidRegion = line . includes ( ":" ) ? line . slice ( line . indexOf ( ":" ) + 1 ) : line ;
83+ const pidMatches = pidRegion . match ( / \d + / g) ?? [ ] ;
84+ for ( const match of pidMatches ) {
85+ const pid = Number . parseInt ( match , 10 ) ;
86+ if ( Number . isFinite ( pid ) && pid > 0 ) {
87+ values . add ( pid ) ;
88+ }
89+ }
90+ }
91+ return [ ...values ] ;
92+ }
93+
94+ function killPortWithFuser ( port : number , signal : "SIGTERM" | "SIGKILL" ) : PortProcess [ ] {
95+ const args = [ "-k" , `-${ FUSER_SIGNALS [ signal ] } ` , `${ port } /tcp` ] ;
96+ try {
97+ const stdout = execFileSync ( "fuser" , args , {
98+ encoding : "utf-8" ,
99+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
100+ } ) ;
101+ return parseFuserPidList ( stdout ) . map ( ( pid ) => ( { pid } ) ) ;
102+ } catch ( err : unknown ) {
103+ const execErr = err as ExecFileError ;
104+ const code = execErr . code ;
105+ const status = execErr . status ;
106+ const stdout = readExecOutput ( execErr . stdout ) ;
107+ const stderr = readExecOutput ( execErr . stderr ) ;
108+ const parsed = parseFuserPidList ( [ stdout , stderr ] . filter ( Boolean ) . join ( "\n" ) ) ;
109+ if ( status === 1 ) {
110+ // fuser exits 1 if nothing matched; keep any parsed PIDs in case signal succeeded.
111+ return parsed . map ( ( pid ) => ( { pid } ) ) ;
112+ }
113+ if ( code === "ENOENT" ) {
114+ throw withErrnoCode (
115+ "fuser not found; required for --force when lsof is unavailable" ,
116+ "ENOENT" ,
117+ err ,
118+ ) ;
119+ }
120+ if ( code === "EACCES" || code === "EPERM" ) {
121+ throw withErrnoCode ( "fuser permission denied while forcing gateway port" , code , err ) ;
122+ }
123+ throw err instanceof Error ? err : new Error ( String ( err ) ) ;
124+ }
125+ }
126+
127+ async function isPortBusy ( port : number ) : Promise < boolean > {
128+ try {
129+ await tryListenOnPort ( { port, exclusive : true } ) ;
130+ return false ;
131+ } catch ( err : unknown ) {
132+ const code = ( err as NodeJS . ErrnoException ) . code ;
133+ if ( code === "EADDRINUSE" ) {
134+ return true ;
135+ }
136+ throw err instanceof Error ? err : new Error ( String ( err ) ) ;
137+ }
138+ }
139+
13140export function parseLsofOutput ( output : string ) : PortProcess [ ] {
14141 const lines = output . split ( / \r ? \n / ) . filter ( Boolean ) ;
15142 const results : PortProcess [ ] = [ ] ;
@@ -38,12 +165,27 @@ export function listPortListeners(port: number): PortProcess[] {
38165 } ) ;
39166 return parseLsofOutput ( out ) ;
40167 } catch ( err : unknown ) {
41- const status = ( err as { status ?: number } ) . status ;
42- const code = ( err as { code ?: string } ) . code ;
168+ const execErr = err as ExecFileError ;
169+ const status = execErr . status ?? undefined ;
170+ const code = execErr . code ;
43171 if ( code === "ENOENT" ) {
44- throw new Error ( "lsof not found; required for --force" , { cause : err } ) ;
172+ throw withErrnoCode ( "lsof not found; required for --force" , "ENOENT" , err ) ;
173+ }
174+ if ( code === "EACCES" || code === "EPERM" ) {
175+ throw withErrnoCode ( "lsof permission denied while inspecting gateway port" , code , err ) ;
45176 }
46177 if ( status === 1 ) {
178+ const stderr = readExecOutput ( execErr . stderr ) . trim ( ) ;
179+ if (
180+ stderr &&
181+ / p e r m i s s i o n d e n i e d | n o t p e r m i t t e d | o p e r a t i o n n o t p e r m i t t e d | c a n ' t s t a t / i. test ( stderr )
182+ ) {
183+ throw withErrnoCode (
184+ `lsof permission denied while inspecting gateway port: ${ stderr } ` ,
185+ "EACCES" ,
186+ err ,
187+ ) ;
188+ }
47189 return [ ] ;
48190 } // no listeners
49191 throw err instanceof Error ? err : new Error ( String ( err ) ) ;
@@ -93,43 +235,65 @@ export async function forceFreePortAndWait(
93235 const intervalMs = Math . max ( opts . intervalMs ?? 100 , 1 ) ;
94236 const sigtermTimeoutMs = Math . min ( Math . max ( opts . sigtermTimeoutMs ?? 600 , 0 ) , timeoutMs ) ;
95237
96- const killed = forceFreePort ( port ) ;
97- if ( killed . length === 0 ) {
238+ let killed : PortProcess [ ] = [ ] ;
239+ let useFuserFallback = false ;
240+
241+ try {
242+ killed = forceFreePort ( port ) ;
243+ } catch ( err ) {
244+ if ( ! isRecoverableLsofError ( err ) ) {
245+ throw err ;
246+ }
247+ useFuserFallback = true ;
248+ killed = killPortWithFuser ( port , "SIGTERM" ) ;
249+ }
250+
251+ const checkBusy = async ( ) : Promise < boolean > =>
252+ useFuserFallback ? isPortBusy ( port ) : listPortListeners ( port ) . length > 0 ;
253+
254+ if ( ! ( await checkBusy ( ) ) ) {
98255 return { killed, waitedMs : 0 , escalatedToSigkill : false } ;
99256 }
100257
101258 let waitedMs = 0 ;
102259 const triesSigterm = intervalMs > 0 ? Math . ceil ( sigtermTimeoutMs / intervalMs ) : 0 ;
103260 for ( let i = 0 ; i < triesSigterm ; i ++ ) {
104- if ( listPortListeners ( port ) . length === 0 ) {
261+ if ( ! ( await checkBusy ( ) ) ) {
105262 return { killed, waitedMs, escalatedToSigkill : false } ;
106263 }
107264 await sleep ( intervalMs ) ;
108265 waitedMs += intervalMs ;
109266 }
110267
111- if ( listPortListeners ( port ) . length === 0 ) {
268+ if ( ! ( await checkBusy ( ) ) ) {
112269 return { killed, waitedMs, escalatedToSigkill : false } ;
113270 }
114271
115- const remaining = listPortListeners ( port ) ;
116- killPids ( remaining , "SIGKILL" ) ;
272+ if ( useFuserFallback ) {
273+ killPortWithFuser ( port , "SIGKILL" ) ;
274+ } else {
275+ const remaining = listPortListeners ( port ) ;
276+ killPids ( remaining , "SIGKILL" ) ;
277+ }
117278
118279 const remainingBudget = Math . max ( timeoutMs - waitedMs , 0 ) ;
119280 const triesSigkill = intervalMs > 0 ? Math . ceil ( remainingBudget / intervalMs ) : 0 ;
120281 for ( let i = 0 ; i < triesSigkill ; i ++ ) {
121- if ( listPortListeners ( port ) . length === 0 ) {
282+ if ( ! ( await checkBusy ( ) ) ) {
122283 return { killed, waitedMs, escalatedToSigkill : true } ;
123284 }
124285 await sleep ( intervalMs ) ;
125286 waitedMs += intervalMs ;
126287 }
127288
128- const still = listPortListeners ( port ) ;
129- if ( still . length === 0 ) {
289+ if ( ! ( await checkBusy ( ) ) ) {
130290 return { killed, waitedMs, escalatedToSigkill : true } ;
131291 }
132292
293+ if ( useFuserFallback ) {
294+ throw new Error ( `port ${ port } still has listeners after --force (fuser fallback)` ) ;
295+ }
296+ const still = listPortListeners ( port ) ;
133297 throw new Error (
134298 `port ${ port } still has listeners after --force: ${ still . map ( ( p ) => p . pid ) . join ( ", " ) } ` ,
135299 ) ;
0 commit comments