Skip to content

Commit eb3ed90

Browse files
committed
fix: scope run port cleanup to verified listeners
1 parent b99e012 commit eb3ed90

2 files changed

Lines changed: 209 additions & 25 deletions

File tree

cli.js

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ function resolveWebPort() {
224224
}
225225

226226
// #region releaseRunPortIfNeeded
227-
function releaseRunPortIfNeeded(port, deps = {}) {
227+
function releaseRunPortIfNeeded(port, host, deps = {}) {
228228
const numericPort = parseInt(String(port), 10);
229229
if (numericPort !== DEFAULT_WEB_PORT) {
230230
return { attempted: false, released: false, pids: [], reason: 'non-default-port' };
@@ -239,19 +239,72 @@ function releaseRunPortIfNeeded(port, deps = {}) {
239239
const seenPids = new Set();
240240
const candidatePids = new Set();
241241
const currentPid = Number(processRef.pid);
242+
const normalizedHost = typeof host === 'string' ? host.trim().toLowerCase() : '';
242243
let released = false;
244+
const windowsCommandLineCache = new Map();
245+
246+
const isManagedRunCommand = (commandLine) => {
247+
const normalizedLine = ` ${String(commandLine || '').replace(/\s+/g, ' ').trim()} `;
248+
return /(^|[\/\\\s])codexmate(?:\.cmd|\.exe)? run(\s|$)/i.test(normalizedLine)
249+
|| /(^|[\/\\\s])cli\.js run(\s|$)/i.test(normalizedLine);
250+
};
251+
252+
const normalizeListenerHost = (value) => {
253+
const trimmed = String(value || '').trim().toLowerCase();
254+
if (!trimmed) {
255+
return '';
256+
}
257+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
258+
return trimmed.slice(1, -1);
259+
}
260+
return trimmed.startsWith('::ffff:') ? trimmed.slice('::ffff:'.length) : trimmed;
261+
};
262+
263+
const extractListenerHost = (localAddress) => {
264+
const trimmed = String(localAddress || '').trim();
265+
if (!trimmed) {
266+
return '';
267+
}
268+
if (trimmed.startsWith('[')) {
269+
const closingBracket = trimmed.indexOf(']');
270+
if (closingBracket > 0) {
271+
return normalizeListenerHost(trimmed.slice(1, closingBracket));
272+
}
273+
}
274+
const lastColon = trimmed.lastIndexOf(':');
275+
if (lastColon <= 0) {
276+
return normalizeListenerHost(trimmed);
277+
}
278+
return normalizeListenerHost(trimmed.slice(0, lastColon));
279+
};
280+
281+
const isMatchingWindowsListenerAddress = (localAddress) => {
282+
const listenerHost = extractListenerHost(localAddress);
283+
if (!listenerHost || !normalizedHost) {
284+
return false;
285+
}
286+
if (normalizedHost === 'localhost') {
287+
return listenerHost === '127.0.0.1' || listenerHost === '::1';
288+
}
289+
if (normalizedHost === '0.0.0.0' || normalizedHost === '::') {
290+
return listenerHost === normalizedHost;
291+
}
292+
return listenerHost === normalizeListenerHost(normalizedHost);
293+
};
243294

244295
const addPidsFromText = (text, targetSet = seenPids) => {
245296
if (!targetSet) {
246297
return;
247298
}
248299
const lines = String(text || '').split(/\r?\n/);
249300
for (const line of lines) {
250-
const trimmed = line.trim();
251-
if (!/^\d+$/.test(trimmed)) {
252-
continue;
301+
const tokens = line.trim().split(/\s+/).filter(Boolean);
302+
for (const token of tokens) {
303+
if (!/^\d+$/.test(token)) {
304+
continue;
305+
}
306+
targetSet.add(Number(token));
253307
}
254-
targetSet.add(Number(trimmed));
255308
}
256309
};
257310

@@ -288,8 +341,28 @@ function releaseRunPortIfNeeded(port, deps = {}) {
288341
}
289342
};
290343

344+
const getWindowsProcessCommandLine = (pid) => {
345+
if (windowsCommandLineCache.has(pid)) {
346+
return windowsCommandLineCache.get(pid);
347+
}
348+
const result = runCommand(
349+
'powershell',
350+
[
351+
'-NoProfile',
352+
'-Command',
353+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { $p.CommandLine }`
354+
],
355+
{ stdoutPidSet: null, stderrPidSet: null }
356+
);
357+
const commandLine = !result.error && result.status === 0
358+
? String(result.stdout || '').trim()
359+
: '';
360+
windowsCommandLineCache.set(pid, commandLine);
361+
return commandLine;
362+
};
363+
291364
if (processRef.platform === 'win32') {
292-
const netstatResult = runCommand('netstat', ['-ano', '-p', 'tcp']);
365+
const netstatResult = runCommand('netstat', ['-ano', '-p', 'tcp'], { stdoutPidSet: null, stderrPidSet: null });
293366
if (!(netstatResult && netstatResult.error)) {
294367
const lines = String(netstatResult.stdout || '').split(/\r?\n/);
295368
for (const line of lines) {
@@ -303,10 +376,24 @@ function releaseRunPortIfNeeded(port, deps = {}) {
303376
if (state !== 'LISTENING' || !localAddress.endsWith(`:${numericPort}`) || !/^\d+$/.test(pidText)) {
304377
continue;
305378
}
306-
seenPids.add(Number(pidText));
379+
if (!isMatchingWindowsListenerAddress(localAddress)) {
380+
continue;
381+
}
382+
candidatePids.add(Number(pidText));
307383
}
308-
for (const pid of seenPids) {
309-
const taskkillResult = runCommand('taskkill', ['/PID', String(pid), '/F']);
384+
for (const pid of candidatePids) {
385+
if (pid === currentPid) {
386+
continue;
387+
}
388+
if (!isManagedRunCommand(getWindowsProcessCommandLine(pid))) {
389+
continue;
390+
}
391+
seenPids.add(pid);
392+
const taskkillResult = runCommand(
393+
'taskkill',
394+
['/PID', String(pid), '/F'],
395+
{ stdoutPidSet: null, stderrPidSet: null }
396+
);
310397
if (!taskkillResult.error && taskkillResult.status === 0) {
311398
released = true;
312399
}
@@ -327,18 +414,20 @@ function releaseRunPortIfNeeded(port, deps = {}) {
327414
['-ti', `tcp:${numericPort}`],
328415
{ stdoutPidSet: candidatePids, stderrPidSet: null }
329416
);
330-
if (!(lsofResult && lsofResult.error) && candidatePids.size > 0) {
417+
const shouldTryFuser = !!(lsofResult && lsofResult.error && lsofResult.error.code === 'ENOENT');
418+
if (shouldTryFuser && candidatePids.size === 0) {
419+
runCommand(
420+
'fuser',
421+
[`${numericPort}/tcp`],
422+
{ stdoutPidSet: candidatePids, stderrPidSet: candidatePids }
423+
);
424+
}
425+
if (candidatePids.size > 0) {
331426
const managedPsResult = readPsResult();
332427
if (!(managedPsResult && managedPsResult.error)) {
333428
addManagedRunPidsFromPs(managedPsResult.stdout, candidatePids);
334429
}
335430
}
336-
if (killProcess && seenPids.size === 0) {
337-
const managedPsResult = readPsResult();
338-
if (!(managedPsResult && managedPsResult.error)) {
339-
addManagedRunPidsFromPs(managedPsResult.stdout);
340-
}
341-
}
342431
}
343432

344433
if (processRef.platform !== 'win32' && killProcess && !released && seenPids.size > 0) {
@@ -10988,7 +11077,7 @@ function cmdStart(options = {}) {
1098811077

1098911078
const port = resolveWebPort();
1099011079
const host = resolveWebHost(options);
10991-
releaseRunPortIfNeeded(port);
11080+
releaseRunPortIfNeeded(port, host);
1099211081

1099311082
let serverHandle = createWebServer({
1099411083
htmlPath,

tests/unit/web-run-host.test.mjs

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const cmdStartSource = extractFunctionBySignature(
125125
);
126126
const releaseRunPortIfNeededSource = extractFunctionBySignature(
127127
cliContent,
128-
'function releaseRunPortIfNeeded(port, deps = {}) {',
128+
'function releaseRunPortIfNeeded(port, host, deps = {}) {',
129129
'releaseRunPortIfNeeded'
130130
);
131131
const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', {
@@ -175,7 +175,7 @@ test('releaseRunPortIfNeeded skips non-default ports', () => {
175175
console: { log() {} }
176176
});
177177

178-
const result = releaseRunPortIfNeeded(3999);
178+
const result = releaseRunPortIfNeeded(3999, '0.0.0.0');
179179
assert.deepStrictEqual(result, {
180180
attempted: false,
181181
released: false,
@@ -218,7 +218,7 @@ test('releaseRunPortIfNeeded clears default port only after lsof pids map to man
218218
console: { log(message) { logs.push(message); } }
219219
});
220220

221-
const result = releaseRunPortIfNeeded(3737);
221+
const result = releaseRunPortIfNeeded(3737, '0.0.0.0');
222222
assert.deepStrictEqual(calls, [
223223
['lsof', ['-ti', 'tcp:3737']],
224224
['ps', ['-ef']]
@@ -232,7 +232,7 @@ test('releaseRunPortIfNeeded clears default port only after lsof pids map to man
232232
assert.deepStrictEqual(logs, ['~ 已释放端口 3737 占用']);
233233
});
234234

235-
test('releaseRunPortIfNeeded falls back to ps scan when lsof is unavailable', () => {
235+
test('releaseRunPortIfNeeded falls back to non-destructive fuser pids when lsof is unavailable', () => {
236236
const calls = [];
237237
const killed = [];
238238
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
@@ -242,6 +242,9 @@ test('releaseRunPortIfNeeded falls back to ps scan when lsof is unavailable', ()
242242
if (command === 'lsof') {
243243
return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' };
244244
}
245+
if (command === 'fuser') {
246+
return { status: 0, stdout: '', stderr: '2222 3333' };
247+
}
245248
if (command === 'ps') {
246249
return {
247250
status: 0,
@@ -264,9 +267,10 @@ test('releaseRunPortIfNeeded falls back to ps scan when lsof is unavailable', ()
264267
console: { log() {} }
265268
});
266269

267-
const result = releaseRunPortIfNeeded(3737);
270+
const result = releaseRunPortIfNeeded(3737, '0.0.0.0');
268271
assert.deepStrictEqual(calls, [
269272
['lsof', ['-ti', 'tcp:3737']],
273+
['fuser', ['3737/tcp']],
270274
['ps', ['-ef']]
271275
]);
272276
assert.deepStrictEqual(killed, [
@@ -314,7 +318,7 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', (
314318
console: { log() {} }
315319
});
316320

317-
const result = releaseRunPortIfNeeded(3737);
321+
const result = releaseRunPortIfNeeded(3737, '0.0.0.0');
318322
assert.deepStrictEqual(calls, [
319323
['lsof', ['-ti', 'tcp:3737']],
320324
['ps', ['-ef']]
@@ -327,13 +331,104 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', (
327331
});
328332
});
329333

334+
test('releaseRunPortIfNeeded skips ps-based kill when no port-scoped owner pids can be identified', () => {
335+
const calls = [];
336+
const killed = [];
337+
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
338+
DEFAULT_WEB_PORT: 3737,
339+
spawnSync(command, args) {
340+
calls.push([command, args]);
341+
if (command === 'lsof') {
342+
return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' };
343+
}
344+
if (command === 'fuser') {
345+
return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' };
346+
}
347+
if (command === 'ps') {
348+
throw new Error('ps should not run without port-scoped pid candidates');
349+
}
350+
throw new Error(`unexpected command: ${command}`);
351+
},
352+
process: {
353+
platform: 'linux',
354+
kill(pid, signal) {
355+
killed.push([pid, signal]);
356+
}
357+
},
358+
console: { log() {} }
359+
});
360+
361+
const result = releaseRunPortIfNeeded(3737, '0.0.0.0');
362+
assert.deepStrictEqual(calls, [
363+
['lsof', ['-ti', 'tcp:3737']],
364+
['fuser', ['3737/tcp']]
365+
]);
366+
assert.deepStrictEqual(killed, []);
367+
assert.deepStrictEqual(result, {
368+
attempted: true,
369+
released: false,
370+
pids: []
371+
});
372+
});
373+
374+
test('releaseRunPortIfNeeded only taskkills Windows listeners that match host and managed command line', () => {
375+
const calls = [];
376+
const logs = [];
377+
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
378+
DEFAULT_WEB_PORT: 3737,
379+
spawnSync(command, args) {
380+
calls.push([command, args]);
381+
if (command === 'netstat') {
382+
return {
383+
status: 0,
384+
stdout: [
385+
' TCP 0.0.0.0:3737 0.0.0.0:0 LISTENING 1111',
386+
' TCP 127.0.0.1:3737 0.0.0.0:0 LISTENING 2222',
387+
' TCP 0.0.0.0:3737 0.0.0.0:0 LISTENING 3333'
388+
].join('\r\n'),
389+
stderr: ''
390+
};
391+
}
392+
if (command === 'powershell') {
393+
if (String(args[2]).includes('1111')) {
394+
return { status: 0, stdout: 'node C:\\repo\\cli.js run --no-browser\r\n', stderr: '' };
395+
}
396+
if (String(args[2]).includes('3333')) {
397+
return { status: 0, stdout: 'node C:\\repo\\other-server.js\r\n', stderr: '' };
398+
}
399+
throw new Error(`unexpected powershell args: ${args.join(' ')}`);
400+
}
401+
if (command === 'taskkill') {
402+
return { status: 0, stdout: '', stderr: '' };
403+
}
404+
throw new Error(`unexpected command: ${command}`);
405+
},
406+
process: { platform: 'win32', pid: 9999 },
407+
console: { log(message) { logs.push(message); } }
408+
});
409+
410+
const result = releaseRunPortIfNeeded(3737, '0.0.0.0');
411+
assert.deepStrictEqual(calls, [
412+
['netstat', ['-ano', '-p', 'tcp']],
413+
['powershell', ['-NoProfile', '-Command', '$p = Get-CimInstance Win32_Process -Filter "ProcessId = 1111"; if ($p) { $p.CommandLine }']],
414+
['taskkill', ['/PID', '1111', '/F']],
415+
['powershell', ['-NoProfile', '-Command', '$p = Get-CimInstance Win32_Process -Filter "ProcessId = 3333"; if ($p) { $p.CommandLine }']]
416+
]);
417+
assert.deepStrictEqual(result, {
418+
attempted: true,
419+
released: true,
420+
pids: [1111]
421+
});
422+
assert.deepStrictEqual(logs, ['~ 已释放端口 3737 占用']);
423+
});
424+
330425
test('cmdStart releases the resolved port before creating the web server', () => {
331426
const resolveIndex = cmdStartSource.indexOf('resolveWebPort(');
332-
const releaseIndex = cmdStartSource.indexOf('releaseRunPortIfNeeded(');
427+
const releaseIndex = cmdStartSource.indexOf('releaseRunPortIfNeeded(port, host)');
333428
const createIndex = cmdStartSource.indexOf('createWebServer(');
334429

335430
assert(resolveIndex >= 0, 'cmdStart should resolve the web port');
336-
assert(releaseIndex >= 0, 'cmdStart should release the run port before startup');
431+
assert(releaseIndex >= 0, 'cmdStart should release the run port with host before startup');
337432
assert(createIndex >= 0, 'cmdStart should create the web server');
338433
assert(resolveIndex < releaseIndex, 'cmdStart should resolve the port before releasing it');
339434
assert(releaseIndex < createIndex, 'cmdStart should release the port before creating the web server');

0 commit comments

Comments
 (0)