Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,131 @@ function resolveWebPort() {
return parsed;
}

// #region releaseRunPortIfNeeded
function releaseRunPortIfNeeded(port, deps = {}) {
const numericPort = parseInt(String(port), 10);
if (numericPort !== DEFAULT_WEB_PORT) {
return { attempted: false, released: false, pids: [], reason: 'non-default-port' };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

const processRef = deps.process || process;
const runSpawnSync = deps.spawnSync || spawnSync;
const logger = deps.logger || console;
const killProcess = typeof deps.kill === 'function'
? deps.kill
: (typeof processRef.kill === 'function' ? processRef.kill.bind(processRef) : null);
const seenPids = new Set();
const currentPid = Number(processRef.pid);
let released = false;

const addPidsFromText = (text) => {
const lines = String(text || '').split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!/^\d+$/.test(trimmed)) {
continue;
}
seenPids.add(Number(trimmed));
}
};

const runCommand = (command, args) => {
const result = runSpawnSync(command, args, { encoding: 'utf8' });
if (result && result.stdout) addPidsFromText(result.stdout);
if (result && result.stderr) addPidsFromText(result.stderr);
return result || {};
};

const addManagedRunPidsFromPs = (text) => {
const lines = String(text || '').split(/\r?\n/);
for (const line of lines) {
const normalizedLine = ` ${line.replace(/\s+/g, ' ').trim()} `;
if (!/(^|[\/\s])codexmate run(\s|$)/.test(normalizedLine) && !/(^|[\/\s])cli\.js run(\s|$)/.test(normalizedLine)) {
continue;
}
const pidMatch = line.match(/^\S+\s+(\d+)\s+/);
if (!pidMatch) {
continue;
}
const pid = Number(pidMatch[1]);
if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid) {
continue;
}
seenPids.add(pid);
}
};

if (processRef.platform === 'win32') {
const netstatResult = runCommand('netstat', ['-ano', '-p', 'tcp']);
if (!(netstatResult && netstatResult.error)) {
const lines = String(netstatResult.stdout || '').split(/\r?\n/);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 5) {
continue;
}
const localAddress = parts[1];
const state = parts[3];
const pidText = parts[4];
if (state !== 'LISTENING' || !localAddress.endsWith(`:${numericPort}`) || !/^\d+$/.test(pidText)) {
continue;
}
seenPids.add(Number(pidText));
}
for (const pid of seenPids) {
const taskkillResult = runCommand('taskkill', ['/PID', String(pid), '/F']);
if (!taskkillResult.error && taskkillResult.status === 0) {
released = true;
}
}
}
} else {
const fuserResult = runCommand('fuser', ['-k', `${numericPort}/tcp`]);
if (!fuserResult.error && fuserResult.status === 0) {
released = true;
}
const fuserMissing = !!(fuserResult && fuserResult.error && fuserResult.error.code === 'ENOENT');
if ((fuserMissing || !released || seenPids.size === 0) && killProcess) {
const lsofResult = runCommand('lsof', ['-ti', `tcp:${numericPort}`]);
if (!(lsofResult && lsofResult.error)) {
// noop: lsof pid collection is handled through stdout parsing above.
}
}
}

if (processRef.platform !== 'win32' && killProcess && !released && seenPids.size === 0) {
const psResult = runCommand('ps', ['-ef']);
if (!(psResult && psResult.error)) {
addManagedRunPidsFromPs(psResult.stdout);
}
}

if (processRef.platform !== 'win32' && killProcess && !released && seenPids.size > 0) {
for (const pid of seenPids) {
if (pid === currentPid) {
continue;
}
try {
killProcess(pid, 'SIGKILL');
released = true;
Comment on lines +412 to +440
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unix cleanup still kills by port instead of by requested listener.

The non-Windows path ignores host entirely: lsof/fuser collect every managed codexmate run on port 3737, then Line 433 SIGKILLs all verified matches. That can terminate another instance bound to a different specific address even when the requested --host would coexist. Filter Unix candidates by local listener address before they reach seenPids, or skip cleanup when the listener address cannot be verified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli.js` around lines 412 - 440, The cleanup code uses lsof/fuser and
candidatePids/seenPids to kill by port only, ignoring the requested host, so
killProcess(currentPid) may terminate other instances bound to different
addresses; update the non-Windows path (the runCommand/lsof/fuser logic that
populates candidatePids, the call sites readPsResult and
addManagedRunPidsFromPs, and the loop that iterates seenPids before calling
killProcess) to verify each candidate's local listener address matches the
requested host (or skip if verification is impossible) before adding to seenPids
or issuing SIGKILL; i.e., after obtaining PIDs from lsof/fuser/readPsResult,
query the listener address for each PID (or use lsof output flags to include
address) and filter candidatePids so only processes bound to the same host:port
are left, otherwise do not perform cleanup.

} catch (_) {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

if (released) {
logger.log(`~ 已释放端口 ${numericPort} 占用`);
}

return {
attempted: true,
released,
pids: Array.from(seenPids)
.filter((pid) => pid !== currentPid)
.sort((a, b) => a - b)
};
}
// #endregion releaseRunPortIfNeeded

function resolveWebHost(options = {}) {
const optionHost = typeof options.host === 'string' ? options.host.trim() : '';
if (optionHost) {
Expand All @@ -236,7 +361,6 @@ function resolveWebHost(options = {}) {
}

const EMPTY_CONFIG_FALLBACK_TEMPLATE = `model = "gpt-5.3-codex"
model_reasoning_effort = "high"
model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW}
model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT}
disable_response_storage = true
Expand Down Expand Up @@ -10845,6 +10969,7 @@ function cmdStart(options = {}) {

const port = resolveWebPort();
const host = resolveWebHost(options);
releaseRunPortIfNeeded(port);

let serverHandle = createWebServer({
htmlPath,
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/test-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,10 @@ preferred_auth_method = "shadow-key"
/^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m.test(legacyTemplateDefaults.template),
'legacy get-config-template should restore default model_auto_compact_token_limit'
);
assert(
!/^\s*model_reasoning_effort\s*=.+$/m.test(legacyTemplateDefaults.template),
'legacy get-config-template should keep default medium reasoning without model_reasoning_effort'
);
const legacyAddDup = await legacyApi('add-provider', {
name: 'foo.bar',
url: 'https://dup.example.com/v1',
Expand Down
148 changes: 148 additions & 0 deletions tests/unit/web-run-host.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ const resolveWebHostSource = extractFunctionBySignature(
'function resolveWebHost(options = {}) {',
'resolveWebHost'
);
const releaseRunPortIfNeededSource = extractFunctionBySignature(
cliContent,
'function releaseRunPortIfNeeded(port, deps = {}) {',
'releaseRunPortIfNeeded'
);
const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', {
DEFAULT_WEB_HOST: defaultHostMatch[1],
process: { env: {} }
Expand Down Expand Up @@ -153,6 +158,149 @@ test('web auto-open uses IPv6 loopback when binding to IPv6 any address', () =>
);
});

test('releaseRunPortIfNeeded skips non-default ports', () => {
const calls = [];
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
DEFAULT_WEB_PORT: 3737,
spawnSync(command, args) {
calls.push([command, args]);
return { status: 0, stdout: '', stderr: '' };
},
process: { platform: 'linux' },
console: { log() {} }
});

const result = releaseRunPortIfNeeded(3999);
assert.deepStrictEqual(result, {
attempted: false,
released: false,
pids: [],
reason: 'non-default-port'
});
assert.deepStrictEqual(calls, []);
});

test('releaseRunPortIfNeeded clears default port via fuser on linux', () => {
const calls = [];
const logs = [];
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
DEFAULT_WEB_PORT: 3737,
spawnSync(command, args) {
calls.push([command, args]);
if (command === 'fuser') {
return { status: 0, stdout: '1234\n', stderr: '' };
}
throw new Error(`unexpected command: ${command}`);
},
process: { platform: 'linux' },
console: { log(message) { logs.push(message); } }
});

const result = releaseRunPortIfNeeded(3737);
assert.deepStrictEqual(calls, [['fuser', ['-k', '3737/tcp']]]);
assert.deepStrictEqual(result, {
attempted: true,
released: true,
pids: [1234]
});
assert.deepStrictEqual(logs, ['~ 已释放端口 3737 占用']);
});

test('releaseRunPortIfNeeded falls back to lsof pids when fuser is unavailable', () => {
const calls = [];
const killed = [];
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
DEFAULT_WEB_PORT: 3737,
spawnSync(command, args) {
calls.push([command, args]);
if (command === 'fuser') {
return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' };
}
if (command === 'lsof') {
return { status: 0, stdout: '2222\n3333\n', stderr: '' };
}
throw new Error(`unexpected command: ${command}`);
},
process: {
platform: 'linux',
kill(pid, signal) {
killed.push([pid, signal]);
}
},
console: { log() {} }
});

const result = releaseRunPortIfNeeded(3737);
assert.deepStrictEqual(calls, [
['fuser', ['-k', '3737/tcp']],
['lsof', ['-ti', 'tcp:3737']]
]);
assert.deepStrictEqual(killed, [
[2222, 'SIGKILL'],
[3333, 'SIGKILL']
]);
assert.deepStrictEqual(result, {
attempted: true,
released: true,
pids: [2222, 3333]
});
});

test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', () => {
const calls = [];
const killed = [];
const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', {
DEFAULT_WEB_PORT: 3737,
spawnSync(command, args) {
calls.push([command, args]);
if (command === 'fuser') {
return { error: { code: 'EACCES' }, status: 1, stdout: '', stderr: 'Permission denied' };
}
if (command === 'lsof') {
return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' };
}
if (command === 'ps') {
return {
status: 0,
stdout: [
'UID PID PPID C STIME TTY TIME CMD',
'u0_a876 9001 1000 0 1970 ? 00:00:00 node /repo/cli.js run --no-browser',
'u0_a876 9002 1000 0 1970 ? 00:00:00 /usr/bin/codexmate run',
'u0_a876 9100 1000 0 1970 ? 00:00:00 node /repo/cli.js config'
].join('\n'),
stderr: ''
};
}
throw new Error(`unexpected command: ${command}`);
},
process: {
platform: 'linux',
pid: 9002,
kill(pid, signal) {
killed.push([pid, signal]);
}
},
console: { log() {} }
});

const result = releaseRunPortIfNeeded(3737);
assert.deepStrictEqual(calls, [
['fuser', ['-k', '3737/tcp']],
['lsof', ['-ti', 'tcp:3737']],
['ps', ['-ef']]
]);
assert.deepStrictEqual(killed, [[9001, 'SIGKILL']]);
assert.deepStrictEqual(result, {
attempted: true,
released: true,
pids: [9001]
});
});

test('cmdStart releases the resolved port before creating the web server', () => {
assert.match(cliContent, /const port = resolveWebPort\(\);\s*const host = resolveWebHost\(options\);\s*releaseRunPortIfNeeded\(port\);\s*let serverHandle = createWebServer\(/s);
});

const getCodexSkillsDirSource = extractFunctionBySignature(
cliContent,
'function getCodexSkillsDir() {',
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function createLoadAllContext() {
currentProvider: 'existing-provider',
currentModel: 'existing-model',
serviceTier: 'fast',
modelReasoningEffort: 'high',
modelReasoningEffort: 'medium',
modelContextWindowInput: 'dirty-context',
modelAutoCompactTokenLimitInput: 'dirty-limit',
editingCodexBudgetField: 'modelContextWindowInput',
Expand Down
2 changes: 1 addition & 1 deletion web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ document.addEventListener('DOMContentLoaded', () => {
currentProvider: '',
currentModel: '',
serviceTier: 'fast',
modelReasoningEffort: 'high',
modelReasoningEffort: 'medium',
modelContextWindowInput: String(DEFAULT_MODEL_CONTEXT_WINDOW),
modelAutoCompactTokenLimitInput: String(DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT),
editingCodexBudgetField: '',
Expand Down
2 changes: 1 addition & 1 deletion web-ui/modules/app.methods.startup-claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function createStartupClaudeMethods(options = {}) {
const effort = typeof statusRes.modelReasoningEffort === 'string'
? statusRes.modelReasoningEffort.trim().toLowerCase()
: '';
this.modelReasoningEffort = effort || 'high';
this.modelReasoningEffort = effort || 'medium';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
{
const contextWindow = this.normalizePositiveIntegerInput(
Expand Down
4 changes: 2 additions & 2 deletions web-ui/partials/index/panel-config-codex.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@
<span class="selector-title">推理强度</span>
</div>
<select class="model-select" v-model="modelReasoningEffort" @change="onReasoningEffortChange">
<option value="high">high(默认)</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="medium">medium(默认)</option>
<option value="low">low</option>
<option value="xhigh">xhigh</option>
</select>
Expand Down
Loading