From 04af7621428b1f9e2c6674906fe73a0712da9a42 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:20:14 +0800 Subject: [PATCH 1/3] fix: release default web run port and align codex defaults --- cli.js | 127 ++++++++++++++- tests/e2e/test-config.js | 4 + tests/unit/web-run-host.test.mjs | 148 ++++++++++++++++++ tests/unit/web-ui-behavior-parity.test.mjs | 2 +- web-ui/app.js | 2 +- web-ui/modules/app.methods.startup-claude.mjs | 2 +- web-ui/partials/index/panel-config-codex.html | 4 +- 7 files changed, 283 insertions(+), 6 deletions(-) diff --git a/cli.js b/cli.js index 039e7a1..b6bcfbe 100644 --- a/cli.js +++ b/cli.js @@ -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' }; + } + + 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; + } catch (_) {} + } + } + + 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) { @@ -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 @@ -10845,6 +10969,7 @@ function cmdStart(options = {}) { const port = resolveWebPort(); const host = resolveWebHost(options); + releaseRunPortIfNeeded(port); let serverHandle = createWebServer({ htmlPath, diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index 7d399d9..9cedd54 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -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', diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 892f87a..2462acd 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -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: {} } @@ -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() {', diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 5182ed1..9c3b0c7 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -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', diff --git a/web-ui/app.js b/web-ui/app.js index 4c4da4f..cc13d0e 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -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: '', diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 1e3afa7..228be8e 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -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'; } { const contextWindow = this.normalizePositiveIntegerInput( diff --git a/web-ui/partials/index/panel-config-codex.html b/web-ui/partials/index/panel-config-codex.html index 32f8516..0c971e7 100644 --- a/web-ui/partials/index/panel-config-codex.html +++ b/web-ui/partials/index/panel-config-codex.html @@ -78,8 +78,8 @@ 推理强度 From b99e0121b83acd33066c3aec6d77e5a44ae181be Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:18:12 +0800 Subject: [PATCH 2/3] fix: verify run port cleanup targets --- cli.js | 61 +++++++++----- tests/unit/provider-share-command.test.mjs | 81 +++++++++++++++++++ tests/unit/web-run-host.test.mjs | 72 +++++++++++++---- web-ui/modules/app.methods.startup-claude.mjs | 3 +- 4 files changed, 178 insertions(+), 39 deletions(-) diff --git a/cli.js b/cli.js index b6bcfbe..7d73061 100644 --- a/cli.js +++ b/cli.js @@ -237,28 +237,36 @@ function releaseRunPortIfNeeded(port, deps = {}) { ? deps.kill : (typeof processRef.kill === 'function' ? processRef.kill.bind(processRef) : null); const seenPids = new Set(); + const candidatePids = new Set(); const currentPid = Number(processRef.pid); let released = false; - const addPidsFromText = (text) => { + const addPidsFromText = (text, targetSet = seenPids) => { + if (!targetSet) { + return; + } 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)); + targetSet.add(Number(trimmed)); } }; - const runCommand = (command, args) => { + const runCommand = (command, args, options = {}) => { + const { + stdoutPidSet = seenPids, + stderrPidSet = seenPids + } = options; const result = runSpawnSync(command, args, { encoding: 'utf8' }); - if (result && result.stdout) addPidsFromText(result.stdout); - if (result && result.stderr) addPidsFromText(result.stderr); + if (result && result.stdout) addPidsFromText(result.stdout, stdoutPidSet); + if (result && result.stderr) addPidsFromText(result.stderr, stderrPidSet); return result || {}; }; - const addManagedRunPidsFromPs = (text) => { + const addManagedRunPidsFromPs = (text, allowedPids = null) => { const lines = String(text || '').split(/\r?\n/); for (const line of lines) { const normalizedLine = ` ${line.replace(/\s+/g, ' ').trim()} `; @@ -273,6 +281,9 @@ function releaseRunPortIfNeeded(port, deps = {}) { if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid) { continue; } + if (allowedPids && !allowedPids.has(pid)) { + continue; + } seenPids.add(pid); } }; @@ -302,23 +313,31 @@ function releaseRunPortIfNeeded(port, deps = {}) { } } } 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. + let psResult = null; + const readPsResult = () => { + if (psResult) { + return psResult; } - } - } + psResult = runCommand('ps', ['-ef'], { stdoutPidSet: null, stderrPidSet: null }); + return psResult; + }; - if (processRef.platform !== 'win32' && killProcess && !released && seenPids.size === 0) { - const psResult = runCommand('ps', ['-ef']); - if (!(psResult && psResult.error)) { - addManagedRunPidsFromPs(psResult.stdout); + const lsofResult = runCommand( + 'lsof', + ['-ti', `tcp:${numericPort}`], + { stdoutPidSet: candidatePids, stderrPidSet: null } + ); + if (!(lsofResult && lsofResult.error) && candidatePids.size > 0) { + const managedPsResult = readPsResult(); + if (!(managedPsResult && managedPsResult.error)) { + addManagedRunPidsFromPs(managedPsResult.stdout, candidatePids); + } + } + if (killProcess && seenPids.size === 0) { + const managedPsResult = readPsResult(); + if (!(managedPsResult && managedPsResult.error)) { + addManagedRunPidsFromPs(managedPsResult.stdout); + } } } diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index c9a1e89..94f441d 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -757,6 +757,87 @@ test('loadAll can refresh in background without flipping the global loading stat assert.strictEqual(context.initError, ''); }); +test('loadAll falls back to medium for unsupported reasoning effort values while preserving xhigh', async () => { + const loadAllSource = extractBlockBySignature( + appSource, + 'async loadAll(options = {}) {' + ).replace(/^async loadAll/, 'async function loadAll'); + const responses = [ + { + provider: 'alpha', + model: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'bogus', + modelContextWindow: 200000, + modelAutoCompactTokenLimit: 180000, + configReady: true, + initNotice: '' + }, + { + provider: 'alpha', + model: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'xhigh', + modelContextWindow: 200000, + modelAutoCompactTokenLimit: 180000, + configReady: true, + initNotice: '' + } + ]; + let statusIndex = 0; + const loadAll = instantiateFunction(loadAllSource, 'loadAll', { + defaultModelContextWindow: 190000, + defaultModelAutoCompactTokenLimit: 185000, + api: async (action) => { + if (action === 'status') { + return responses[statusIndex++] || responses[responses.length - 1]; + } + if (action === 'list') { + return { + providers: [{ name: 'alpha', url: 'https://api.example.com/v1', hasKey: true }] + }; + } + throw new Error(`Unexpected api action: ${action}`); + } + }); + + const createContext = () => ({ + loading: false, + initError: '', + currentProvider: 'stale-provider', + currentModel: 'stale-model', + serviceTier: 'fast', + modelReasoningEffort: 'high', + modelContextWindowInput: '190000', + modelAutoCompactTokenLimitInput: '185000', + editingCodexBudgetField: '', + providersList: [], + normalizePositiveIntegerInput(value, label, fallback = '') { + const raw = value === undefined || value === null || value === '' + ? String(fallback || '') + : String(value); + const text = raw.trim(); + const numeric = Number.parseInt(text, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { ok: false, error: `${label} invalid` }; + } + return { ok: true, value: numeric, text: String(numeric) }; + }, + showMessage() {}, + maybeShowStarPrompt() {}, + async loadModelsForProvider() {}, + async loadCodexAuthProfiles() {} + }); + + const invalidContext = createContext(); + await loadAll.call(invalidContext); + assert.strictEqual(invalidContext.modelReasoningEffort, 'medium'); + + const xhighContext = createContext(); + await loadAll.call(xhighContext); + assert.strictEqual(xhighContext.modelReasoningEffort, 'xhigh'); +}); + test('loadAll treats provider list fetch failures as startup errors and skips model refresh', async () => { const loadAllSource = extractBlockBySignature( appSource, diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 2462acd..d9d0fc6 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -118,6 +118,11 @@ const resolveWebHostSource = extractFunctionBySignature( 'function resolveWebHost(options = {}) {', 'resolveWebHost' ); +const cmdStartSource = extractFunctionBySignature( + cliContent, + 'function cmdStart(options = {}) {', + 'cmdStart' +); const releaseRunPortIfNeededSource = extractFunctionBySignature( cliContent, 'function releaseRunPortIfNeeded(port, deps = {}) {', @@ -180,24 +185,45 @@ test('releaseRunPortIfNeeded skips non-default ports', () => { assert.deepStrictEqual(calls, []); }); -test('releaseRunPortIfNeeded clears default port via fuser on linux', () => { +test('releaseRunPortIfNeeded clears default port only after lsof pids map to managed run processes', () => { const calls = []; + const killed = []; 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: '' }; + if (command === 'lsof') { + return { status: 0, stdout: '1234\n8888\n', stderr: '' }; + } + if (command === 'ps') { + return { + status: 0, + stdout: [ + 'UID PID PPID C STIME TTY TIME CMD', + 'u0_a876 1234 1000 0 1970 ? 00:00:00 node /repo/cli.js run --no-browser', + 'u0_a876 8888 1000 0 1970 ? 00:00:00 node /repo/other-server.js' + ].join('\n'), + stderr: '' + }; } throw new Error(`unexpected command: ${command}`); }, - process: { platform: 'linux' }, + process: { + platform: 'linux', + kill(pid, signal) { + killed.push([pid, signal]); + } + }, console: { log(message) { logs.push(message); } } }); const result = releaseRunPortIfNeeded(3737); - assert.deepStrictEqual(calls, [['fuser', ['-k', '3737/tcp']]]); + assert.deepStrictEqual(calls, [ + ['lsof', ['-ti', 'tcp:3737']], + ['ps', ['-ef']] + ]); + assert.deepStrictEqual(killed, [[1234, 'SIGKILL']]); assert.deepStrictEqual(result, { attempted: true, released: true, @@ -206,18 +232,26 @@ test('releaseRunPortIfNeeded clears default port via fuser on linux', () => { assert.deepStrictEqual(logs, ['~ 已释放端口 3737 占用']); }); -test('releaseRunPortIfNeeded falls back to lsof pids when fuser is unavailable', () => { +test('releaseRunPortIfNeeded falls back to ps scan when lsof 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') { + if (command === 'lsof') { return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' }; } - if (command === 'lsof') { - return { status: 0, stdout: '2222\n3333\n', stderr: '' }; + if (command === 'ps') { + return { + status: 0, + stdout: [ + 'UID PID PPID C STIME TTY TIME CMD', + 'u0_a876 2222 1000 0 1970 ? 00:00:00 node /repo/cli.js run --no-browser', + 'u0_a876 3333 1000 0 1970 ? 00:00:00 /usr/bin/codexmate run' + ].join('\n'), + stderr: '' + }; } throw new Error(`unexpected command: ${command}`); }, @@ -232,8 +266,8 @@ test('releaseRunPortIfNeeded falls back to lsof pids when fuser is unavailable', const result = releaseRunPortIfNeeded(3737); assert.deepStrictEqual(calls, [ - ['fuser', ['-k', '3737/tcp']], - ['lsof', ['-ti', 'tcp:3737']] + ['lsof', ['-ti', 'tcp:3737']], + ['ps', ['-ef']] ]); assert.deepStrictEqual(killed, [ [2222, 'SIGKILL'], @@ -253,11 +287,8 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', ( 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: '' }; + return { status: 0, stdout: '9001\n9002\n', stderr: '' }; } if (command === 'ps') { return { @@ -285,7 +316,6 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', ( const result = releaseRunPortIfNeeded(3737); assert.deepStrictEqual(calls, [ - ['fuser', ['-k', '3737/tcp']], ['lsof', ['-ti', 'tcp:3737']], ['ps', ['-ef']] ]); @@ -298,7 +328,15 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', ( }); 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 resolveIndex = cmdStartSource.indexOf('resolveWebPort('); + const releaseIndex = cmdStartSource.indexOf('releaseRunPortIfNeeded('); + const createIndex = cmdStartSource.indexOf('createWebServer('); + + assert(resolveIndex >= 0, 'cmdStart should resolve the web port'); + assert(releaseIndex >= 0, 'cmdStart should release the run port before startup'); + assert(createIndex >= 0, 'cmdStart should create the web server'); + assert(resolveIndex < releaseIndex, 'cmdStart should resolve the port before releasing it'); + assert(releaseIndex < createIndex, 'cmdStart should release the port before creating the web server'); }); const getCodexSkillsDirSource = extractFunctionBySignature( diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 228be8e..703b8df 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -40,7 +40,8 @@ export function createStartupClaudeMethods(options = {}) { const effort = typeof statusRes.modelReasoningEffort === 'string' ? statusRes.modelReasoningEffort.trim().toLowerCase() : ''; - this.modelReasoningEffort = effort || 'medium'; + const allowedReasoningEfforts = new Set(['low', 'medium', 'high', 'xhigh']); + this.modelReasoningEffort = allowedReasoningEfforts.has(effort) ? effort : 'medium'; } { const contextWindow = this.normalizePositiveIntegerInput( From eb3ed908efeb5246cfbd1e3f468da057970fa6f8 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:22 +0800 Subject: [PATCH 3/3] fix: scope run port cleanup to verified listeners --- cli.js | 123 ++++++++++++++++++++++++++----- tests/unit/web-run-host.test.mjs | 111 ++++++++++++++++++++++++++-- 2 files changed, 209 insertions(+), 25 deletions(-) diff --git a/cli.js b/cli.js index 7d73061..c3601a7 100644 --- a/cli.js +++ b/cli.js @@ -224,7 +224,7 @@ function resolveWebPort() { } // #region releaseRunPortIfNeeded -function releaseRunPortIfNeeded(port, deps = {}) { +function releaseRunPortIfNeeded(port, host, deps = {}) { const numericPort = parseInt(String(port), 10); if (numericPort !== DEFAULT_WEB_PORT) { return { attempted: false, released: false, pids: [], reason: 'non-default-port' }; @@ -239,7 +239,58 @@ function releaseRunPortIfNeeded(port, deps = {}) { const seenPids = new Set(); const candidatePids = new Set(); const currentPid = Number(processRef.pid); + const normalizedHost = typeof host === 'string' ? host.trim().toLowerCase() : ''; let released = false; + const windowsCommandLineCache = new Map(); + + const isManagedRunCommand = (commandLine) => { + const normalizedLine = ` ${String(commandLine || '').replace(/\s+/g, ' ').trim()} `; + return /(^|[\/\\\s])codexmate(?:\.cmd|\.exe)? run(\s|$)/i.test(normalizedLine) + || /(^|[\/\\\s])cli\.js run(\s|$)/i.test(normalizedLine); + }; + + const normalizeListenerHost = (value) => { + const trimmed = String(value || '').trim().toLowerCase(); + if (!trimmed) { + return ''; + } + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed.slice(1, -1); + } + return trimmed.startsWith('::ffff:') ? trimmed.slice('::ffff:'.length) : trimmed; + }; + + const extractListenerHost = (localAddress) => { + const trimmed = String(localAddress || '').trim(); + if (!trimmed) { + return ''; + } + if (trimmed.startsWith('[')) { + const closingBracket = trimmed.indexOf(']'); + if (closingBracket > 0) { + return normalizeListenerHost(trimmed.slice(1, closingBracket)); + } + } + const lastColon = trimmed.lastIndexOf(':'); + if (lastColon <= 0) { + return normalizeListenerHost(trimmed); + } + return normalizeListenerHost(trimmed.slice(0, lastColon)); + }; + + const isMatchingWindowsListenerAddress = (localAddress) => { + const listenerHost = extractListenerHost(localAddress); + if (!listenerHost || !normalizedHost) { + return false; + } + if (normalizedHost === 'localhost') { + return listenerHost === '127.0.0.1' || listenerHost === '::1'; + } + if (normalizedHost === '0.0.0.0' || normalizedHost === '::') { + return listenerHost === normalizedHost; + } + return listenerHost === normalizeListenerHost(normalizedHost); + }; const addPidsFromText = (text, targetSet = seenPids) => { if (!targetSet) { @@ -247,11 +298,13 @@ function releaseRunPortIfNeeded(port, deps = {}) { } const lines = String(text || '').split(/\r?\n/); for (const line of lines) { - const trimmed = line.trim(); - if (!/^\d+$/.test(trimmed)) { - continue; + const tokens = line.trim().split(/\s+/).filter(Boolean); + for (const token of tokens) { + if (!/^\d+$/.test(token)) { + continue; + } + targetSet.add(Number(token)); } - targetSet.add(Number(trimmed)); } }; @@ -288,8 +341,28 @@ function releaseRunPortIfNeeded(port, deps = {}) { } }; + const getWindowsProcessCommandLine = (pid) => { + if (windowsCommandLineCache.has(pid)) { + return windowsCommandLineCache.get(pid); + } + const result = runCommand( + 'powershell', + [ + '-NoProfile', + '-Command', + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { $p.CommandLine }` + ], + { stdoutPidSet: null, stderrPidSet: null } + ); + const commandLine = !result.error && result.status === 0 + ? String(result.stdout || '').trim() + : ''; + windowsCommandLineCache.set(pid, commandLine); + return commandLine; + }; + if (processRef.platform === 'win32') { - const netstatResult = runCommand('netstat', ['-ano', '-p', 'tcp']); + const netstatResult = runCommand('netstat', ['-ano', '-p', 'tcp'], { stdoutPidSet: null, stderrPidSet: null }); if (!(netstatResult && netstatResult.error)) { const lines = String(netstatResult.stdout || '').split(/\r?\n/); for (const line of lines) { @@ -303,10 +376,24 @@ function releaseRunPortIfNeeded(port, deps = {}) { if (state !== 'LISTENING' || !localAddress.endsWith(`:${numericPort}`) || !/^\d+$/.test(pidText)) { continue; } - seenPids.add(Number(pidText)); + if (!isMatchingWindowsListenerAddress(localAddress)) { + continue; + } + candidatePids.add(Number(pidText)); } - for (const pid of seenPids) { - const taskkillResult = runCommand('taskkill', ['/PID', String(pid), '/F']); + for (const pid of candidatePids) { + if (pid === currentPid) { + continue; + } + if (!isManagedRunCommand(getWindowsProcessCommandLine(pid))) { + continue; + } + seenPids.add(pid); + const taskkillResult = runCommand( + 'taskkill', + ['/PID', String(pid), '/F'], + { stdoutPidSet: null, stderrPidSet: null } + ); if (!taskkillResult.error && taskkillResult.status === 0) { released = true; } @@ -327,18 +414,20 @@ function releaseRunPortIfNeeded(port, deps = {}) { ['-ti', `tcp:${numericPort}`], { stdoutPidSet: candidatePids, stderrPidSet: null } ); - if (!(lsofResult && lsofResult.error) && candidatePids.size > 0) { + const shouldTryFuser = !!(lsofResult && lsofResult.error && lsofResult.error.code === 'ENOENT'); + if (shouldTryFuser && candidatePids.size === 0) { + runCommand( + 'fuser', + [`${numericPort}/tcp`], + { stdoutPidSet: candidatePids, stderrPidSet: candidatePids } + ); + } + if (candidatePids.size > 0) { const managedPsResult = readPsResult(); if (!(managedPsResult && managedPsResult.error)) { addManagedRunPidsFromPs(managedPsResult.stdout, candidatePids); } } - if (killProcess && seenPids.size === 0) { - const managedPsResult = readPsResult(); - if (!(managedPsResult && managedPsResult.error)) { - addManagedRunPidsFromPs(managedPsResult.stdout); - } - } } if (processRef.platform !== 'win32' && killProcess && !released && seenPids.size > 0) { @@ -10988,7 +11077,7 @@ function cmdStart(options = {}) { const port = resolveWebPort(); const host = resolveWebHost(options); - releaseRunPortIfNeeded(port); + releaseRunPortIfNeeded(port, host); let serverHandle = createWebServer({ htmlPath, diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index d9d0fc6..d3e4d55 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -125,7 +125,7 @@ const cmdStartSource = extractFunctionBySignature( ); const releaseRunPortIfNeededSource = extractFunctionBySignature( cliContent, - 'function releaseRunPortIfNeeded(port, deps = {}) {', + 'function releaseRunPortIfNeeded(port, host, deps = {}) {', 'releaseRunPortIfNeeded' ); const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', { @@ -175,7 +175,7 @@ test('releaseRunPortIfNeeded skips non-default ports', () => { console: { log() {} } }); - const result = releaseRunPortIfNeeded(3999); + const result = releaseRunPortIfNeeded(3999, '0.0.0.0'); assert.deepStrictEqual(result, { attempted: false, released: false, @@ -218,7 +218,7 @@ test('releaseRunPortIfNeeded clears default port only after lsof pids map to man console: { log(message) { logs.push(message); } } }); - const result = releaseRunPortIfNeeded(3737); + const result = releaseRunPortIfNeeded(3737, '0.0.0.0'); assert.deepStrictEqual(calls, [ ['lsof', ['-ti', 'tcp:3737']], ['ps', ['-ef']] @@ -232,7 +232,7 @@ test('releaseRunPortIfNeeded clears default port only after lsof pids map to man assert.deepStrictEqual(logs, ['~ 已释放端口 3737 占用']); }); -test('releaseRunPortIfNeeded falls back to ps scan when lsof is unavailable', () => { +test('releaseRunPortIfNeeded falls back to non-destructive fuser pids when lsof is unavailable', () => { const calls = []; const killed = []; const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', { @@ -242,6 +242,9 @@ test('releaseRunPortIfNeeded falls back to ps scan when lsof is unavailable', () if (command === 'lsof') { return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' }; } + if (command === 'fuser') { + return { status: 0, stdout: '', stderr: '2222 3333' }; + } if (command === 'ps') { return { status: 0, @@ -264,9 +267,10 @@ test('releaseRunPortIfNeeded falls back to ps scan when lsof is unavailable', () console: { log() {} } }); - const result = releaseRunPortIfNeeded(3737); + const result = releaseRunPortIfNeeded(3737, '0.0.0.0'); assert.deepStrictEqual(calls, [ ['lsof', ['-ti', 'tcp:3737']], + ['fuser', ['3737/tcp']], ['ps', ['-ef']] ]); assert.deepStrictEqual(killed, [ @@ -314,7 +318,7 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', ( console: { log() {} } }); - const result = releaseRunPortIfNeeded(3737); + const result = releaseRunPortIfNeeded(3737, '0.0.0.0'); assert.deepStrictEqual(calls, [ ['lsof', ['-ti', 'tcp:3737']], ['ps', ['-ef']] @@ -327,13 +331,104 @@ test('releaseRunPortIfNeeded falls back to ps scan for managed run processes', ( }); }); +test('releaseRunPortIfNeeded skips ps-based kill when no port-scoped owner pids can be identified', () => { + const calls = []; + const killed = []; + const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', { + DEFAULT_WEB_PORT: 3737, + spawnSync(command, args) { + calls.push([command, args]); + if (command === 'lsof') { + return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' }; + } + if (command === 'fuser') { + return { error: { code: 'ENOENT' }, status: null, stdout: '', stderr: '' }; + } + if (command === 'ps') { + throw new Error('ps should not run without port-scoped pid candidates'); + } + throw new Error(`unexpected command: ${command}`); + }, + process: { + platform: 'linux', + kill(pid, signal) { + killed.push([pid, signal]); + } + }, + console: { log() {} } + }); + + const result = releaseRunPortIfNeeded(3737, '0.0.0.0'); + assert.deepStrictEqual(calls, [ + ['lsof', ['-ti', 'tcp:3737']], + ['fuser', ['3737/tcp']] + ]); + assert.deepStrictEqual(killed, []); + assert.deepStrictEqual(result, { + attempted: true, + released: false, + pids: [] + }); +}); + +test('releaseRunPortIfNeeded only taskkills Windows listeners that match host and managed command line', () => { + const calls = []; + const logs = []; + const releaseRunPortIfNeeded = instantiateFunction(releaseRunPortIfNeededSource, 'releaseRunPortIfNeeded', { + DEFAULT_WEB_PORT: 3737, + spawnSync(command, args) { + calls.push([command, args]); + if (command === 'netstat') { + return { + status: 0, + stdout: [ + ' TCP 0.0.0.0:3737 0.0.0.0:0 LISTENING 1111', + ' TCP 127.0.0.1:3737 0.0.0.0:0 LISTENING 2222', + ' TCP 0.0.0.0:3737 0.0.0.0:0 LISTENING 3333' + ].join('\r\n'), + stderr: '' + }; + } + if (command === 'powershell') { + if (String(args[2]).includes('1111')) { + return { status: 0, stdout: 'node C:\\repo\\cli.js run --no-browser\r\n', stderr: '' }; + } + if (String(args[2]).includes('3333')) { + return { status: 0, stdout: 'node C:\\repo\\other-server.js\r\n', stderr: '' }; + } + throw new Error(`unexpected powershell args: ${args.join(' ')}`); + } + if (command === 'taskkill') { + return { status: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }, + process: { platform: 'win32', pid: 9999 }, + console: { log(message) { logs.push(message); } } + }); + + const result = releaseRunPortIfNeeded(3737, '0.0.0.0'); + assert.deepStrictEqual(calls, [ + ['netstat', ['-ano', '-p', 'tcp']], + ['powershell', ['-NoProfile', '-Command', '$p = Get-CimInstance Win32_Process -Filter "ProcessId = 1111"; if ($p) { $p.CommandLine }']], + ['taskkill', ['/PID', '1111', '/F']], + ['powershell', ['-NoProfile', '-Command', '$p = Get-CimInstance Win32_Process -Filter "ProcessId = 3333"; if ($p) { $p.CommandLine }']] + ]); + assert.deepStrictEqual(result, { + attempted: true, + released: true, + pids: [1111] + }); + assert.deepStrictEqual(logs, ['~ 已释放端口 3737 占用']); +}); + test('cmdStart releases the resolved port before creating the web server', () => { const resolveIndex = cmdStartSource.indexOf('resolveWebPort('); - const releaseIndex = cmdStartSource.indexOf('releaseRunPortIfNeeded('); + const releaseIndex = cmdStartSource.indexOf('releaseRunPortIfNeeded(port, host)'); const createIndex = cmdStartSource.indexOf('createWebServer('); assert(resolveIndex >= 0, 'cmdStart should resolve the web port'); - assert(releaseIndex >= 0, 'cmdStart should release the run port before startup'); + assert(releaseIndex >= 0, 'cmdStart should release the run port with host before startup'); assert(createIndex >= 0, 'cmdStart should create the web server'); assert(resolveIndex < releaseIndex, 'cmdStart should resolve the port before releasing it'); assert(releaseIndex < createIndex, 'cmdStart should release the port before creating the web server');