Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 11 additions & 4 deletions src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
rdkDocsAutoConnectEnabled,
readRdkDocsFlag,
resolveRdkDocsPackage,
shouldEagerWarmRdkDocs,
withBuiltinRdkDocs,
} from './cli/rdk-docs-mcp.js';
import { McpToolRegistry, buildMcpStableIndex } from './core/mcp/registry.js';
Expand Down Expand Up @@ -1199,11 +1200,17 @@ async function main() {
// setTimeout(0) still starts npx immediately each session. It does not
// wait for the first question. The timer is unref'd so a failed warm
// does not hold the process open by itself.
// O87: under HTTPS_PROXY/HTTP_PROXY, skip the eager warm so npx registry
// CONNECTs do not contend with the first model request on a narrow
// reverse tunnel (board: MCP-on FAIL / MOSS_NO_RDK_DOCS=1 PASS).
// First rdk-docs tool call still starts the server.
const warming = mcpRegistry;
const warmTimer = setTimeout(() => {
warming.startDeferred(RDK_DOCS_SERVER_NAME);
}, 0);
if (typeof warmTimer.unref === 'function') warmTimer.unref();
if (shouldEagerWarmRdkDocs(process.env)) {
const warmTimer = setTimeout(() => {
warming.startDeferred(RDK_DOCS_SERVER_NAME);
}, 0);
if (typeof warmTimer.unref === 'function') warmTimer.unref();
}
// Lazy-loading budget: the system prompt gets one index line per server,
// never the tool list itself. The rdk-docs usage pointer sits on that
// server's line; a failed connect gets only the unavailable sentence.
Expand Down
3 changes: 3 additions & 0 deletions src/cli/config-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s
'MOSS_PRICE_IN',
'MOSS_PRICE_OUT',
'MOSS_DISABLE_CONN_WARMUP',
'MOSS_CONNECT_TIMEOUT_MS (ms; proxy connect timeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = library default)',
'MOSS_LLM_FIRST_CHUNK_TIMEOUT_MS',
'MOSS_PI_AI_FIRST_EVENT_TIMEOUT_MS',
'MOSS_PI_AI_INTER_EVENT_TIMEOUT_MS',
Expand Down Expand Up @@ -509,6 +510,8 @@ const ENV_NOTE_ZH: Record<string, string> = {
'1|true|yes|on 向 npm 查询文档包的最新版是否新于钉住的版本;默认关闭,启动时不用',
'0 stops acceptance commands from appending the login-shell PATH':
'设为 0 时,验收命令不再追加登录环境里的 PATH',
'ms; proxy connect timeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = library default':
'毫秒;代理连接超时;设置 HTTPS_PROXY/HTTP_PROXY 时默认 30000;0 = 库默认',
'legacy alias': '旧别名',
'auto|en|zh; process env or --lang; a project .env cannot set this; flag > env > config language > system locale':
'auto|en|zh;进程环境或 --lang;项目 .env 不能设置;优先级为参数、环境变量、配置 language、系统区域',
Expand Down
12 changes: 12 additions & 0 deletions src/cli/rdk-docs-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ export function hasDeviceTarget(workspaceDir: string, env: NodeJS.ProcessEnv): b
return loadDeviceRegistry(workspaceDir).length > 0;
}

/**
* O87 / board H3: on a narrow reverse-tunnel HTTPS_PROXY path, eager rdk-docs
* npx warm (registry CONNECTs) contends with the first model request and can
* leave CONNECT-without-answer + truncated 网关原文:). Skip the warm when a
* proxy env is set; the first rdk-docs tool call still starts the server.
* Board lock: /workspace/ux/board-o67-o70-baseline.md (NO_RDK_DOCS=1 PASS /
* MCP-on FAIL on the same tunnel).
*/
export function shouldEagerWarmRdkDocs(env: NodeJS.ProcessEnv = process.env): boolean {
return !Boolean(env.HTTPS_PROXY || env.HTTP_PROXY || env.https_proxy || env.http_proxy);
}

/**
* Whether this session should connect the builtin server.
* On unless MOSS_NO_RDK_DOCS or `rdkDocs: false`. A device target is not required.
Expand Down
15 changes: 15 additions & 0 deletions src/provider/connection-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ function classifyConnectionHint(
`DNS lookup failed for ${host} — check your network connection and DNS settings. If using a VPN/proxy, ensure DNS is routed correctly.`
);
}
// O87: undici connectTimeout while a proxy is set — blame the tunnel/proxy path,
// never a model "switch to a faster model" timeout.
if (
causeCode === 'UND_ERR_CONNECT_TIMEOUT' ||
(typeof causeName === 'string' && /ConnectTimeout/i.test(causeName))
) {
if (hasProxyEnv) {
return hint(
`Timed out connecting to ${host} via HTTPS_PROXY/HTTP_PROXY (undici connectTimeout). Check that the proxy tunnel can complete CONNECT and TLS; if the path is a reverse SSH tunnel, avoid parallel heavy npm/git traffic, or raise MOSS_CONNECT_TIMEOUT_MS.`
);
}
return hint(
`Timed out connecting to ${host} (undici connectTimeout). Check network, DNS, firewall, and that the gateway is reachable.`
);
}
if (causeCode === 'ECONNREFUSED') {
if (hasProxyEnv) {
const proxy =
Expand Down
70 changes: 61 additions & 9 deletions src/provider/error-classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,29 +239,51 @@ function isMossConnectionLead(line: string): boolean {
if (/^Proxy refused the connection to \S+/i.test(flat)) return true;
if (/^Proxy\/protocol error connecting to \S+$/i.test(flat)) return true;
if (/^Check that \S+ is reachable/i.test(flat)) return true;
if (/^Proxy connection refused while reaching \S+/i.test(flat)) return true;
if (/^Timed out connecting to \S+ via (?:HTTPS_PROXY|HTTP_PROXY)/i.test(flat)) return true;
return (
/^(?:TypeError: )?fetch failed for \S+/i.test(flat) &&
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH/i.test(flat)
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|UND_ERR_|Connect Timeout Error/i.test(
flat
)
);
}

/** O87: a lone ")" / punctuation leftover is not a gateway body. */
function isPunctuationOnlyDetail(text: string): boolean {
const flat = text.replace(/\s+/g, '').trim();
if (!flat) return true;
for (const ch of flat) {
if (!'()[]{}<>.,;:!?\'"`-+~|/\\'.includes(ch)) return false;
}
return true;
}

/** A syscall or client abort, not a body the gateway wrote. */
function isTransportOnlyLine(line: string): boolean {
if (isMossConnectionLead(line)) return true;
const flat = line.replace(/\s+/g, ' ').trim();
if (!flat || isHtmlDocument(flat)) return !flat;
// O87: punctuation-only leftovers (e.g. a lone ")") are not gateway bodies.
if (isPunctuationOnlyDetail(flat)) return true;
if (
/^(?:[A-Za-z]+ )?(?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|EAI_NONAME|EAI_FAIL|EAI_NODATA)\b/i.test(
/^(?:[A-Za-z]+ )?(?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|EAI_NONAME|EAI_FAIL|EAI_NODATA|UND_ERR_[A-Z0-9_]+)\b/i.test(
flat
)
) {
return true;
}
if (/^Connect Timeout Error\b/i.test(flat)) return true;
if (/^(?:TypeError: )?fetch failed\b/i.test(flat)) return true;
if (/^connection refused\b/i.test(flat)) return true;
if (/^getaddrinfo\b/i.test(flat)) return true;
if (/^(?:AbortError|TimeoutError|HeadersTimeoutError|BodyTimeoutError)\b/i.test(flat))
if (
/^(?:AbortError|TimeoutError|HeadersTimeoutError|BodyTimeoutError|ConnectTimeoutError)\b/i.test(
flat
)
) {
return true;
}
return /^The operation was aborted\b/i.test(flat);
}

Expand All @@ -275,19 +297,35 @@ function stripAuthoredTails(text: string): string {
return out;
}

/** O87: keep syscall/undici codes under 详细信息, not English prose that duplicates the hint. */
function isCodeBearingCause(text: string): boolean {
return (
/\b(?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|EAI_NONAME|EAI_FAIL|EAI_NODATA|UND_ERR_[A-Z0-9_]+)\b/i.test(
text
) ||
/\b(?:ConnectTimeoutError|HeadersTimeoutError|BodyTimeoutError|TimeoutError|AbortError)\b/i.test(
text
)
);
}

/** Syscall or client-abort text shown under 详细信息, never as gateway body. */
function lowLevelCause(raw: string): string {
const cleaned = stripLoginPagePrefix(raw);
const paren = cleaned.match(
/\(((?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE)\b[^)]{0,180})\)/i
/\(((?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|UND_ERR_[A-Z0-9_]+)\b[^)]{0,180})\)/i
);
if (paren?.[1]) return paren[1].trim();
for (const line of cleaned.split('\n')) {
let text = line.trim();
if (!text || isMossConnectionLead(text)) continue;
text = stripAuthoredTails(text).trim();
if (!text || isMossConnectionLead(text)) continue;
if (isTransportOnlyLine(text)) return text.slice(0, 300);
// Bare "connection refused" (no ECONNREFUSED/UND_ERR_*) is already covered by the zh hint.
if (isTransportOnlyLine(text)) {
if (!isCodeBearingCause(text)) continue;
return text.slice(0, 300);
}
}
return '';
}
Expand All @@ -307,9 +345,16 @@ function gatewayDetail(raw: string): string {
.trim();
}

/** O87: refuse punctuation-only / empty "gateway" leftovers such as a lone ")". */
function isJunkGatewayDetail(detail: string): boolean {
const flat = detail.replace(/\s+/g, ' ').trim();
if (!flat) return true;
return isPunctuationOnlyDetail(flat);
}

function withGatewayText(hint: string, raw: string, audience?: 'setup' | 'chat'): string {
const detail = gatewayDetail(raw);
if (detail)
if (detail && !isJunkGatewayDetail(detail))
return hint.includes(detail)
? hint
: `${hint}\n${msg('网关原文:', 'Gateway text: ')}${detail}`;
Expand Down Expand Up @@ -401,7 +446,7 @@ function face(
return { category, userMessage, actions, silent: false, retryable };
}

type GateAttach = 'none' | 'body' | 'setup';
type GateAttach = 'none' | 'body' | 'setup' | 'details';

/** User-facing classifier copy. English is the fallback; zh is the zh voice. */
const COPY = {
Expand Down Expand Up @@ -546,7 +591,13 @@ function classifyProviderErrorNow(input: ProviderErrorInput): ProviderErrorSurfa
category,
attach === 'none'
? text
: withGatewayText(text, raw, attach === 'setup' ? input.audience : undefined),
: withGatewayText(
text,
raw,
// 'details' = always attach low-level cause (O87 chat + setup).
// 'setup' still respects audience so chat timeouts stay brief.
attach === 'details' ? 'setup' : attach === 'setup' ? input.audience : undefined
),
actions,
retryable
);
Expand Down Expand Up @@ -598,8 +649,9 @@ function classifyProviderErrorNow(input: ProviderErrorInput): ProviderErrorSurfa
);
// O67: when a proxy is configured, a refused connect is almost always the
// proxy port — do not blame the model base URL.
// O87: attach='details' so chat keeps cause codes (no empty 网关原文:)).
const id = refused ? (proxySet ? 'refusedProxy' : 'refused') : 'connect';
return hit('network', id, RETRY_SETTINGS, true, 'setup');
return hit('network', id, RETRY_SETTINGS, true, 'details');
}
if (isTlsProtocolMismatch(raw, code))
return hit('network', 'httpNotTls', SETTINGS_RETRY, false, 'body');
Expand Down
21 changes: 20 additions & 1 deletion src/provider/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ const CONNECTION_PATTERNS = [
'ehostunreach',
'enetunreach',
'unreachable',
// O87: undici connect-phase failures (must not be misread as model timeout).
'und_err_connect_timeout',
'und_err_socket',
'connect timeout error',
];

const AUTH_PATTERNS = [
Expand Down Expand Up @@ -254,15 +258,30 @@ export function isQuotaExceededError(message?: string): boolean {
);
}

/**
* undici / TCP connect-phase timeout (proxy tunnel, DNS, TCP).
* Must stay under network — never the model "switch to a faster model" path.
*/
export function isConnectTimeoutError(message?: string): boolean {
if (!message) return false;
return /und_err_connect_timeout|connect timeout error|\bconnect(?:ion)? timed?\s*out\b/i.test(
message
);
}

export function isTimeoutError(message?: string): boolean {
if (!message) return false;
// O87: connect-phase timeouts contain the substring "timeout" but are network.
if (isConnectTimeoutError(message)) return false;
if (matchesAny(message, TIMEOUT_PATTERNS)) return true;
// "timedout"/"timed-out" (no space) variants — same judgment as 'timed out'.
return /\btimed?[ _-]?out\b/i.test(message);
}

export function isConnectionError(message?: string): boolean {
return !!message && matchesAny(message, CONNECTION_PATTERNS);
if (!message) return false;
if (isConnectTimeoutError(message)) return true;
return matchesAny(message, CONNECTION_PATTERNS);
}

export function isServerError(message?: string): boolean {
Expand Down
31 changes: 30 additions & 1 deletion src/provider/keep-alive-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ function proxyEnvOptions(): {
};
}

/** Default undici connectTimeout under HTTPS_PROXY/HTTP_PROXY (O87). */
const PROXY_CONNECT_TIMEOUT_MS = 30_000;

/**
* Resolve undici `connectTimeout` for the global dispatcher.
* - No proxy: leave undefined (undici default 10s).
* - Proxy: 30s, unless MOSS_CONNECT_TIMEOUT_MS is set (0 = undici default).
*/
export function resolveConnectTimeoutMs(proxyConfigured: boolean): number | undefined {
const raw = process.env.MOSS_CONNECT_TIMEOUT_MS;
if (raw != null && String(raw).trim() !== '') {
const n = Number(raw);
if (Number.isFinite(n) && n >= 0) {
return n === 0 ? undefined : Math.floor(n);
}
}
return proxyConfigured ? PROXY_CONNECT_TIMEOUT_MS : undefined;
}

export async function ensureKeepAliveDispatcherInstalled(): Promise<void> {
if (installed) return;
if (typeof process === 'undefined' || !process.versions?.node) return;
Expand All @@ -63,10 +82,20 @@ async function installKeepAliveDispatcher(): Promise<void> {
}
const { Agent, EnvHttpProxyAgent, setGlobalDispatcher } = mod;

const common = {
// O87: undici defaults connectTimeout to 10s. Board → ssh -R → HTTPS_PROXY →
// gateway often needs longer after CONNECT; raise only when a proxy is set.
// Override with MOSS_CONNECT_TIMEOUT_MS (milliseconds). 0 keeps undici default.
const connectTimeout = resolveConnectTimeoutMs(hasProxyEnv());
const common: {
keepAliveTimeout: number;
keepAliveMaxTimeout: number;
connections: number;
connectTimeout?: number;
} = {
keepAliveTimeout: 60_000,
keepAliveMaxTimeout: 600_000,
connections: 8,
...(connectTimeout !== undefined ? { connectTimeout } : {}),
};

let nextDispatcher: InstanceType<typeof Agent> | InstanceType<typeof EnvHttpProxyAgent>;
Expand Down
4 changes: 3 additions & 1 deletion test/error-classify-500.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ for (const errorMessage of [
}

{
// O87: connection failures always attach low-level cause (chat + setup).
const r = classifyProviderError({
errorMessage: 'connect ECONNREFUSED 127.0.0.1:9',
status: undefined,
});
assert.equal(r.category, 'network');
assert.match(r.userMessage, /Fix the base URL/);
assert.doesNotMatch(r.userMessage, /Gateway text|网关原文|Details:|详细信息/);
assert.match(r.userMessage, /Details:.*ECONNREFUSED/);
assert.doesNotMatch(r.userMessage, /Gateway text|网关原文/);
}

console.error('error-classify: HTTP 500 is now retryable (was non-retryable unknown) ✓');
Loading
Loading