From caba42ba27181fdf474423f0c5d37cd09c52ec28 Mon Sep 17 00:00:00 2001 From: Moss Developer Date: Sun, 11 Oct 2026 15:46:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(cli):=20O70+O67=20exit=20reliability=20?= =?UTF-8?q?=E2=80=94=20stop=20bg=20exec,=20exit-guard,=20proxy=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop background exec on CLI shutdown and agent.close; arm 2s -p exit guard (stderr warning; MOSS_DEBUG_EXIT=1 lists handles). - First-chunk timeout copy: no "switching models"; with HTTPS_PROXY do not retry. - Refused proxy: name the proxy, not "Nothing is listening at that base URL". - QA acceptance: test/qa-repro-o67-o70-exit-bundle.spec.mjs (4/4). Base: ed638367. Not pushed. --- src/cli-main.ts | 7 + src/cli/config-commands.ts | 1 + src/cli/exit-guard.ts | 37 ++++ src/core/agent/moss-agent.ts | 5 + src/core/loop/agent-loop-stream-helpers.ts | 14 +- src/provider/connection-error.ts | 11 ++ src/provider/error-classify.ts | 20 +- src/provider/llm-error-classifier.ts | 10 +- test/connection-error-proxy-hint.spec.mjs | 23 ++- test/loop-first-chunk-hard-timeout.spec.mjs | 2 +- test/o67-refused-proxy-copy.spec.mjs | 56 ++++++ test/o70-exit-hang.spec.mjs | 97 ++++++++++ test/qa-repro-o67-o70-exit-bundle.spec.mjs | 201 ++++++++++++++++++++ 13 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 src/cli/exit-guard.ts create mode 100644 test/o67-refused-proxy-copy.spec.mjs create mode 100644 test/o70-exit-hang.spec.mjs create mode 100644 test/qa-repro-o67-o70-exit-bundle.spec.mjs diff --git a/src/cli-main.ts b/src/cli-main.ts index fe8242e92..057460086 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -63,6 +63,8 @@ import { } from './cli/model-catalog.js'; import { createModelInfoTool } from './cli/model-info-tool.js'; import { runOneShotWithCliCancellation } from './cli/run-cancellation.js'; +import { armExitGuard } from './cli/exit-guard.js'; +import { stopAllBackgroundProcesses } from './core/tools/background-process-registry.js'; import { runInteractive } from './cli/repl.js'; import { resolveCliSession } from './cli/session.js'; import { @@ -1794,6 +1796,9 @@ async function main() { } } finally { try { + // O70: exec_start children are detached with piped stdout/stderr; left + // running they hold the event loop open and `moss -p` never returns. + stopAllBackgroundProcesses(); await agent.close(); await disconnectAllDevices(); } finally { @@ -1813,6 +1818,8 @@ async function main() { } catch { /* shutdown hooks never block exit */ } + // O70: whatever still lingers after cleanup must not hang a one-shot run. + if (parsedArgs.print || oneShotMessage) armExitGuard(); } } } diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index da51ca610..bb3b08953 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -399,6 +399,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s 'MOSS_PI_AI_INTER_EVENT_TIMEOUT_MS', 'MOSS_PI_AI_TOOL_CHOICE', 'MOSS_TRACE_PI_AI_STREAM', + 'MOSS_DEBUG_EXIT', ], }, { diff --git a/src/cli/exit-guard.ts b/src/cli/exit-guard.ts new file mode 100644 index 000000000..63bff2e66 --- /dev/null +++ b/src/cli/exit-guard.ts @@ -0,0 +1,37 @@ +/** + * O70: `moss -p` must exit within 2 s after the answer is printed. + * + * Normal exit relies on the event loop draining. Any handle left behind (a + * background exec child whose stdout/stderr pipes are still open, an MCP + * child that ignores SIGTERM, a stray ref'd timer or socket) keeps the + * process alive. The guard is an unref'd timer: when the loop drains on its + * own it never fires; when something lingers it forces the exit, always prints a + * stderr warning with the number of open handles, and with MOSS_DEBUG_EXIT=1 + * also names them. + */ +export const EXIT_GUARD_MS = 2000; + +export function describeActiveHandles(): string[] { + const getter = (process as unknown as { getActiveResourcesInfo?: () => string[] }) + .getActiveResourcesInfo; + return typeof getter === 'function' ? getter.call(process) : []; +} + +export function armExitGuard( + ms: number = EXIT_GUARD_MS, + log: (line: string) => void = (line) => process.stderr.write(`${line}\n`) +): NodeJS.Timeout { + const timer = setTimeout(() => { + // Always warn so the force-exit never hides a leak (O70 acceptance). + const handles = describeActiveHandles(); + log( + `[moss] warning: forcing exit after ${ms} ms; ${handles.length} handle(s) still open` + + (process.env.MOSS_DEBUG_EXIT === '1' + ? `: ${handles.join(', ')}` + : ' (set MOSS_DEBUG_EXIT=1 for details)') + ); + process.exit(typeof process.exitCode === 'number' ? process.exitCode : 0); + }, ms); + timer.unref(); + return timer; +} diff --git a/src/core/agent/moss-agent.ts b/src/core/agent/moss-agent.ts index 5c1afb67c..810bae606 100644 --- a/src/core/agent/moss-agent.ts +++ b/src/core/agent/moss-agent.ts @@ -6,6 +6,7 @@ import { buildExperienceBlock, experienceEnabled } from '../experience/experienc const log = getRootLogger().child('agent'); import { ToolRegistry } from '../tools/tool-registry.js'; +import { stopAllBackgroundProcesses } from '../tools/background-process-registry.js'; import { filterToolsForRun } from '../tools/tool-filter.js'; import { DeferredToolOffer, TOOL_SEARCH_NAME } from '../tools/deferred-tool-offer.js'; import { @@ -220,6 +221,10 @@ export class MossAgent { this.rootAbortController.abort( new MossError({ code: ErrorCode.USER_ABORTED, message: 'Agent closed' }) ); + // O70: background exec_start children hold stdout/stderr pipes open; stop + // them whenever the agent closes so hosts that skip the CLI finally path + // still drain the event loop. + stopAllBackgroundProcesses(); this.pendingToolAborts.dispose(); const asyncTasks = this.ownsAsyncTaskRegistry ? (this.asyncTasks.stopAll?.('parent_aborted').then(() => {}) ?? Promise.resolve()) diff --git a/src/core/loop/agent-loop-stream-helpers.ts b/src/core/loop/agent-loop-stream-helpers.ts index 03752c785..60a7647eb 100644 --- a/src/core/loop/agent-loop-stream-helpers.ts +++ b/src/core/loop/agent-loop-stream-helpers.ts @@ -505,7 +505,7 @@ export async function runAgentLoopLlmTurn( } if (firstChunkTimedOut && !abortSignal.aborted) { throw new LlmFirstChunkTimeoutError( - `LLM produced no streaming output (including thinking) within ${Math.round(firstChunkBudgetMs / 1000)}s. Check network/proxy, Base URL, API Key and model availability; or try disabling extended thinking or switching models.` + `No response from the model within ${Math.round(firstChunkBudgetMs / 1000)}s (no streaming output, including thinking). Check network/proxy, Base URL, API Key, and that the gateway is reachable.` ); } throw streamErr; @@ -521,7 +521,17 @@ export async function runAgentLoopLlmTurn( label: 'llm-call', shouldRetry: (err, attempt) => { if (abortSignal.aborted) return false; - if (err instanceof LlmFirstChunkTimeoutError && attempt >= 2) return false; + if (err instanceof LlmFirstChunkTimeoutError) { + // O67: a blackhole HTTPS_PROXY yields zero bytes forever — retrying + // only multiplies the hang. One recovery retry is enough without a proxy. + const proxySet = Boolean( + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.http_proxy + ); + if (proxySet || attempt >= 2) return false; + } return classifyLlmError(err).retryable; }, retryDelayMs: (err, attempt, _computed) => { diff --git a/src/provider/connection-error.ts b/src/provider/connection-error.ts index d4c4a34c3..13e7607db 100644 --- a/src/provider/connection-error.ts +++ b/src/provider/connection-error.ts @@ -59,6 +59,17 @@ function classifyConnectionHint( ); } if (causeCode === 'ECONNREFUSED') { + if (hasProxyEnv) { + const proxy = + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.http_proxy || + 'HTTP_PROXY/HTTPS_PROXY'; + return hint( + `Proxy connection refused while reaching ${host} — could not connect to the proxy at ${proxy}. Check HTTPS_PROXY/HTTP_PROXY (host, port, and that the proxy is running).` + ); + } return hint( `Connection refused by ${host} — the server is not running or the port is wrong. Check that the gateway/service is up and the baseUrl port is correct.` ); diff --git a/src/provider/error-classify.ts b/src/provider/error-classify.ts index 983d5a39d..056474663 100644 --- a/src/provider/error-classify.ts +++ b/src/provider/error-classify.ts @@ -449,6 +449,10 @@ const COPY = { '连接被拒绝。这个地址的主机或端口没有服务在听。请改地址,然后重试。', 'Connection refused. Nothing is listening at that base URL (wrong host or port). Fix the base URL, then retry.', ], + refusedProxy: [ + '代理连接被拒绝。请检查 HTTPS_PROXY/HTTP_PROXY(主机、端口,以及代理是否在运行)。', + 'Proxy connection refused. Check HTTPS_PROXY/HTTP_PROXY (host, port, and that the proxy is running).', + ], connect: [ '网络连接失败。请检查地址和网络,然后重试。', 'Could not connect. Check the base URL and your network, then retry.', @@ -585,13 +589,17 @@ function classifyProviderErrorNow(input: ProviderErrorInput): ProviderErrorSurfa } if (isRateLimitFailure(raw, status)) return hit('rate_limit', 'rate', [RETRY], true); if (isConnectionError(raw)) { - return hit( - 'network', - /econnrefused|connection refused/i.test(raw) ? 'refused' : 'connect', - RETRY_SETTINGS, - true, - 'setup' + const refused = /econnrefused|connection refused/i.test(raw); + const proxySet = Boolean( + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.http_proxy ); + // O67: when a proxy is configured, a refused connect is almost always the + // proxy port — do not blame the model base URL. + const id = refused ? (proxySet ? 'refusedProxy' : 'refused') : 'connect'; + return hit('network', id, RETRY_SETTINGS, true, 'setup'); } if (isTlsProtocolMismatch(raw, code)) return hit('network', 'httpNotTls', SETTINGS_RETRY, false, 'body'); diff --git a/src/provider/llm-error-classifier.ts b/src/provider/llm-error-classifier.ts index bd6520e4f..d9bccb31e 100644 --- a/src/provider/llm-error-classifier.ts +++ b/src/provider/llm-error-classifier.ts @@ -75,7 +75,15 @@ export function classifyLlmError(error: unknown): LlmErrorClassification { // mention API keys and otherwise look like a non-retryable auth failure. // Checking the name avoids a dependency cycle with the stream helper. if (error instanceof Error && error.name === 'LlmFirstChunkTimeoutError') { - return { category: 'timeout', retryable: true, message }; + // O67: with a proxy configured, a silent first-chunk stall is almost always + // a dead/blackhole proxy — do not burn 3× the budget retrying. + const proxySet = Boolean( + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.http_proxy + ); + return { category: 'timeout', retryable: !proxySet, message }; } if (isAbortFailure(message)) { return { category: 'user_abort', retryable: false, message }; diff --git a/test/connection-error-proxy-hint.spec.mjs b/test/connection-error-proxy-hint.spec.mjs index 4b5c46021..13c47b1aa 100644 --- a/test/connection-error-proxy-hint.spec.mjs +++ b/test/connection-error-proxy-hint.spec.mjs @@ -112,6 +112,27 @@ try { globalThis.fetch = originalFetch; } +// ─── 5. ECONNREFUSED with proxy env → blame the proxy, not the base URL ───── +process.env.HTTP_PROXY = 'http://127.0.0.1:9'; +process.env.HTTPS_PROXY = 'http://127.0.0.1:9'; +globalThis.fetch = async () => { + const e = new TypeError('fetch failed'); + e.cause = { code: 'ECONNREFUSED', message: 'connect ECONNREFUSED 127.0.0.1:9' }; + throw e; +}; +try { + await fetchWithConnectionContext('https://api.example.com/v1/x', { method: 'GET' }); + assert.fail('should have thrown'); +} catch (err) { + assert.ok(err instanceof MossError); + assert.match(err.hint, /[Pp]roxy/, `proxy-named hint, got: ${err.hint}`); + assert.doesNotMatch(err.hint, /baseUrl port is correct/); +} finally { + globalThis.fetch = originalFetch; + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; +} + console.log( - ' [PASS] connection-error: proxy-tunnel refusal hint + DNS + TLS CA + no-false-positive' + ' [PASS] connection-error: proxy-tunnel refusal hint + DNS + TLS CA + no-false-positive + refused-proxy' ); diff --git a/test/loop-first-chunk-hard-timeout.spec.mjs b/test/loop-first-chunk-hard-timeout.spec.mjs index a20f3642f..c4cfb9cd4 100644 --- a/test/loop-first-chunk-hard-timeout.spec.mjs +++ b/test/loop-first-chunk-hard-timeout.spec.mjs @@ -35,7 +35,7 @@ try { firstTokenMs: null, logDebug() {}, }), - /no streaming output.*within 0s/i + /No response from the model within 0s.*no streaming output/i ); assert.equal(calls, 2, 'a silent first-chunk timeout gets at most one recovery retry'); assert.ok( diff --git a/test/o67-refused-proxy-copy.spec.mjs b/test/o67-refused-proxy-copy.spec.mjs new file mode 100644 index 000000000..e86ff5cca --- /dev/null +++ b/test/o67-refused-proxy-copy.spec.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { classifyProviderError } from '../dist/provider/error-classify.js'; + +test('refused connect with HTTPS_PROXY blames the proxy, not the base URL', () => { + const prev = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + https_proxy: process.env.https_proxy, + http_proxy: process.env.http_proxy, + }; + process.env.HTTPS_PROXY = 'http://127.0.0.1:9'; + delete process.env.HTTP_PROXY; + delete process.env.https_proxy; + delete process.env.http_proxy; + try { + const r = classifyProviderError({ + errorMessage: 'connect ECONNREFUSED 127.0.0.1:9', + locale: 'en', + }); + assert.equal(r.category, 'network'); + assert.match(r.userMessage, /[Pp]roxy/); + assert.doesNotMatch(r.userMessage, /Nothing is listening at that base URL/); + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +}); + +test('refused connect without proxy still blames the base URL', () => { + const prev = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + https_proxy: process.env.https_proxy, + http_proxy: process.env.http_proxy, + }; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + delete process.env.https_proxy; + delete process.env.http_proxy; + try { + const r = classifyProviderError({ + errorMessage: 'connect ECONNREFUSED 127.0.0.1:9', + locale: 'en', + }); + assert.match(r.userMessage, /Fix the base URL|Nothing is listening at that base URL/); + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +}); diff --git a/test/o70-exit-hang.spec.mjs b/test/o70-exit-hang.spec.mjs new file mode 100644 index 000000000..c92db732f --- /dev/null +++ b/test/o70-exit-hang.spec.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// O70: a one-shot run must exit within 2 s even when a handle lingers. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// Uses POSIX `sleep` and process-group kill (kill(-pid)); no equivalent on win32. +const posixOnly = + process.platform === 'win32' + ? 'O70 spec relies on POSIX sleep and process-group kill(-pid); Windows covered by O70 follow-up' + : false; + +const guard = fileURLToPath(new URL('../dist/cli/exit-guard.js', import.meta.url)); +const registry = fileURLToPath( + new URL('../dist/core/tools/background-process-registry.js', import.meta.url) +); + +function runChild(source, debug = false) { + return new Promise((resolve) => { + const started = Date.now(); + const child = spawn(process.execPath, ['--input-type=module', '-e', source], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, MOSS_DEBUG_EXIT: debug ? '1' : '' }, + }); + let stderr = ''; + child.stderr.on('data', (c) => (stderr += c)); + const kill = setTimeout(() => child.kill('SIGKILL'), 8000); + child.on('close', (code, signal) => { + clearTimeout(kill); + resolve({ code, signal, ms: Date.now() - started, stderr }); + }); + }); +} + +test( + 'baseline: a lingering detached child with pipes keeps node alive (the O70 hang)', + { skip: posixOnly }, + async () => { + const r = await runChild(` + import { spawn } from 'node:child_process'; + spawn('sleep', ['30'], { stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + `); + assert.equal(r.signal, 'SIGKILL', 'without the guard the process should still be alive at 8 s'); + } +); + +test( + 'exit guard forces exit within ~2 s and keeps the exit code', + { skip: posixOnly }, + async () => { + const r = await runChild(` + import { spawn } from 'node:child_process'; + import { armExitGuard } from ${JSON.stringify(guard)}; + const c = spawn('sleep', ['30'], { stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + setInterval(() => {}, 1000); + process.exitCode = 3; + armExitGuard(); + process.on('exit', () => { try { process.kill(-c.pid, 'SIGKILL'); } catch {} }); + `); + assert.equal(r.code, 3); + assert.ok(r.ms < 4000, `took ${r.ms} ms`); + // Warning is printed by default (no debug flag) and reports the open-handle count. + assert.match( + r.stderr, + /\[moss\] warning: forcing exit after 2000 ms; [1-9]\d* handle\(s\) still open/ + ); + } +); + +test('MOSS_DEBUG_EXIT=1 adds the handle names to the warning', { skip: posixOnly }, async () => { + const r = await runChild( + ` + import { armExitGuard } from ${JSON.stringify(guard)}; + setInterval(() => {}, 1000); + armExitGuard(); + `, + true + ); + assert.equal(r.code, 0); + assert.match(r.stderr, /handle\(s\) still open: .+/); +}); + +test('exit guard does not delay a clean exit', async () => { + const r = await runChild(` + import { armExitGuard } from ${JSON.stringify(guard)}; + armExitGuard(); + `); + assert.equal(r.code, 0); + assert.ok(r.ms < 1500, `took ${r.ms} ms`); + assert.doesNotMatch(r.stderr, /forcing exit/); +}); + +test('stopAllBackgroundProcesses is exported for CLI shutdown', async () => { + const mod = await import(registry); + assert.equal(typeof mod.stopAllBackgroundProcesses, 'function'); +}); diff --git a/test/qa-repro-o67-o70-exit-bundle.spec.mjs b/test/qa-repro-o67-o70-exit-bundle.spec.mjs new file mode 100644 index 000000000..c565edcb0 --- /dev/null +++ b/test/qa-repro-o67-o70-exit-bundle.spec.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/** + * O70 + O67 blackhole exit bundle (QA failing repro). + * + * Acceptance: + * - Dead HTTPS_PROXY: moss -p exits by itself; error names network/proxy; no "switching models". + * - Refused proxy: mentions proxy, not "Nothing is listening at that base URL". + * - Source locks: first-chunk timeout copy and cli-main background stop. + * + * Expected on main ed638367: FAIL. After exit-bundle fix: PASS. + */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { isolatedCliEnv } from './helpers/isolated-cli-env.mjs'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const CLI = path.join(ROOT, 'dist', 'cli.js'); +const PROXY_PY = '/workspace/ux/acceptance/proxy.py'; + +const MODEL_COACH = + /switching models|switch models|try another model|faster model|换一个.*模型|更快的模型/i; +const NET_OR_PROXY = + /network|proxy|HTTPS_PROXY|代理|网络|connect(ion)? (failed|refused|timed out)/i; + +function put(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +async function startBlackholeProxy() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-bh-')); + const portFile = path.join(dir, 'port'); + const logFile = path.join(dir, 'log'); + const child = spawn('python3', [PROXY_PY, 'blackhole', portFile, logFile], { + stdio: 'ignore', + }); + for (let i = 0; i < 100 && !fs.existsSync(portFile); i++) await sleep(20); + if (!fs.existsSync(portFile)) { + child.kill('SIGKILL'); + throw new Error('blackhole proxy did not write port file'); + } + const port = Number(fs.readFileSync(portFile, 'utf8').trim()); + return { + port, + logFile, + stop() { + try { + child.kill('SIGKILL'); + } catch { + /* ignore */ + } + fs.rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +function closedPort() { + const s = net.createServer(); + return new Promise((resolve) => { + s.listen(0, '127.0.0.1', () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); +} + +function runMoss({ env, args, cwd, killAfterMs }) { + return new Promise((resolve) => { + const started = Date.now(); + const child = spawn(process.execPath, [CLI, ...args], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (b) => (stdout += b)); + child.stderr.on('data', (b) => (stderr += b)); + let killed = false; + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + }, killAfterMs); + child.on('close', (code, signal) => { + clearTimeout(timer); + resolve({ + code, + signal, + killed, + ms: Date.now() - started, + out: `${stdout}\n${stderr}`, + }); + }); + }); +} + +function prepareEnv(extra = {}) { + const env = isolatedCliEnv({ + prefix: 'moss-exit-bundle-', + overrides: { + MOSS_NO_TUI: '1', + MOSS_NO_COLOR: '1', + MOSS_NO_BUNDLED_DEFAULT: '1', + MOSS_LLM_FIRST_CHUNK_TIMEOUT_MS: '4000', + LANG: 'C', + LC_ALL: 'C', + ...extra, + }, + }); + delete env.NODE_USE_ENV_PROXY; + delete env.MOSS_TRUST_WORKSPACE; + const ws = path.join(env.HOME, 'ws'); + fs.mkdirSync(ws, { recursive: true }); + put(path.join(env.MOSS_CONFIG_DIR, 'config.json'), { + provider: 'openai-compatible', + model: 'stub-model', + baseUrl: 'https://api.deepseek.com/v1', + apiKey: 'sk-acceptance-exit-bundle', + permissions: { defaultMode: 'full' }, + }); + return { env, ws }; +} + +test('O67 copy lock: first-chunk timeout must not coach switching models', () => { + const src = fs.readFileSync( + path.join(ROOT, 'src/core/loop/agent-loop-stream-helpers.ts'), + 'utf8' + ); + assert.doesNotMatch( + src, + /switching models/, + 'LlmFirstChunkTimeoutError message must not tell users to switch models' + ); +}); + +test('O70: CLI exit path must call stopAllBackgroundProcesses', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/cli-main.ts'), 'utf8'); + assert.match( + src, + /stopAllBackgroundProcesses\s*\(/, + 'cli-main must call stopAllBackgroundProcesses on the exit path' + ); +}); + +test('O67/O70: blackhole HTTPS_PROXY — self-exit; no model coaching', async () => { + const proxy = await startBlackholeProxy(); + const { env, ws } = prepareEnv({ + HTTPS_PROXY: `http://127.0.0.1:${proxy.port}`, + }); + try { + const r = await runMoss({ + env, + cwd: ws, + args: ['-p', 'reply with just: ok'], + killAfterMs: 45_000, + }); + const log = fs.existsSync(proxy.logFile) ? fs.readFileSync(proxy.logFile, 'utf8') : ''; + assert.ok(/BLACKHOLE/.test(log), `HTTPS_PROXY must be contacted; log=${log}`); + assert.equal( + r.killed, + false, + `moss must exit by itself after a dead proxy (still running at ${r.ms}ms)` + ); + assert.notEqual(r.code, 0, 'dead proxy must fail'); + assert.doesNotMatch(r.out, MODEL_COACH, `must not coach models:\n${r.out.slice(-800)}`); + assert.match(r.out, NET_OR_PROXY, `must name network/proxy:\n${r.out.slice(-800)}`); + assert.ok(r.ms < 35_000, `took ${r.ms}ms`); + } finally { + proxy.stop(); + } +}); + +test('O67: refused proxy — blame proxy, not base URL', async () => { + const port = await closedPort(); + const { env, ws } = prepareEnv({ + HTTPS_PROXY: `http://127.0.0.1:${port}`, + }); + const r = await runMoss({ + env, + cwd: ws, + args: ['-p', 'reply with just: ok'], + killAfterMs: 30_000, + }); + assert.equal(r.killed, false, `must not hang (${r.ms}ms)`); + assert.notEqual(r.code, 0); + assert.doesNotMatch(r.out, MODEL_COACH, r.out.slice(-500)); + assert.match(r.out, /proxy|代理/i, `must mention proxy:\n${r.out.slice(-800)}`); + assert.doesNotMatch( + r.out, + /Nothing is listening at that base URL/i, + 'must not blame base URL when proxy refused' + ); +}); From 4b783fc73a1d33ebe9a020926bf06a1a4e178027 Mon Sep 17 00:00:00 2001 From: Laura Cook Date: Sun, 11 Oct 2026 16:01:00 +0800 Subject: [PATCH 2/2] test(o70): in-process Node blackhole proxy for CI Replace the hardcoded /workspace/ux/acceptance/proxy.py spawn with an in-test net.createServer on 127.0.0.1. That UX path only exists on the agent box, so Ubuntu CI never wrote the port file and qa-repro-o67-o70 failed. Assertions unchanged (BLACKHOLE contact, self-exit, no model coaching, refused-proxy copy). --- test/qa-repro-o67-o70-exit-bundle.spec.mjs | 67 ++++++++++++++-------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/test/qa-repro-o67-o70-exit-bundle.spec.mjs b/test/qa-repro-o67-o70-exit-bundle.spec.mjs index c565edcb0..04b5c61c6 100644 --- a/test/qa-repro-o67-o70-exit-bundle.spec.mjs +++ b/test/qa-repro-o67-o70-exit-bundle.spec.mjs @@ -17,13 +17,11 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { setTimeout as sleep } from 'node:timers/promises'; import { isolatedCliEnv } from './helpers/isolated-cli-env.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const CLI = path.join(ROOT, 'dist', 'cli.js'); -const PROXY_PY = '/workspace/ux/acceptance/proxy.py'; const MODEL_COACH = /switching models|switch models|try another model|faster model|换一个.*模型|更快的模型/i; @@ -35,31 +33,52 @@ function put(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); } -async function startBlackholeProxy() { +/** + * In-process blackhole HTTPS proxy for CI. + * Accepts TCP on 127.0.0.1, logs BLACKHOLE, reads forever, never replies. + * (Previously shelled out to /workspace/ux/acceptance/proxy.py — that path + * only exists on the agent box, so CI never got a port file.) + */ +function startBlackholeProxy() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-bh-')); - const portFile = path.join(dir, 'port'); const logFile = path.join(dir, 'log'); - const child = spawn('python3', [PROXY_PY, 'blackhole', portFile, logFile], { - stdio: 'ignore', + fs.writeFileSync(logFile, ''); + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + fs.appendFileSync(logFile, 'BLACKHOLE accepted\n'); + // Read and discard; never reply (simulates a dead proxy). + socket.on('data', () => {}); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ + port, + logFile, + stop() { + for (const s of sockets) { + try { + s.destroy(); + } catch { + /* ignore */ + } + } + sockets.clear(); + try { + server.close(); + } catch { + /* ignore */ + } + fs.rmSync(dir, { recursive: true, force: true }); + }, + }); + }); }); - for (let i = 0; i < 100 && !fs.existsSync(portFile); i++) await sleep(20); - if (!fs.existsSync(portFile)) { - child.kill('SIGKILL'); - throw new Error('blackhole proxy did not write port file'); - } - const port = Number(fs.readFileSync(portFile, 'utf8').trim()); - return { - port, - logFile, - stop() { - try { - child.kill('SIGKILL'); - } catch { - /* ignore */ - } - fs.rmSync(dir, { recursive: true, force: true }); - }, - }; } function closedPort() {