diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index 9ab46e60..b06a4e08 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -1,6 +1,7 @@ /** * Binary Downloader * Handles downloading files with retry logic, progress tracking, and redirect following. + * Robust handling for transient network errors (socket hang up, ECONNRESET, etc.) */ import * as fs from 'fs'; @@ -14,40 +15,111 @@ export interface DownloaderConfig { maxRetries: number; /** Enable verbose logging */ verbose: boolean; + /** Timeout in milliseconds (default: 120000 for large files) */ + timeout?: number; } const DEFAULT_CONFIG: DownloaderConfig = { - maxRetries: 3, + maxRetries: 5, verbose: false, + timeout: 120000, // 2 minutes for large binaries }; +/** Error types for categorized handling */ +export type NetworkErrorType = 'socket' | 'timeout' | 'http' | 'redirect' | 'unknown'; + +/** Categorize error for appropriate retry/reporting */ +export function categorizeError(error: Error): NetworkErrorType { + const msg = error.message.toLowerCase(); + if (msg.includes('socket hang up') || msg.includes('econnreset') || msg.includes('epipe')) { + return 'socket'; + } + if (msg.includes('timeout') || msg.includes('etimedout')) { + return 'timeout'; + } + if (msg.includes('http ')) { + return 'http'; + } + if (msg.includes('redirect')) { + return 'redirect'; + } + return 'unknown'; +} + +/** Get user-friendly error message */ +export function getErrorMessage(error: Error, attempt: number, maxAttempts: number): string { + const type = categorizeError(error); + const prefix = `[Attempt ${attempt}/${maxAttempts}]`; + + switch (type) { + case 'socket': + return `${prefix} Connection dropped (socket hang up) - retrying with fresh connection...`; + case 'timeout': + return `${prefix} Download timed out - retrying with extended timeout...`; + case 'http': + return `${prefix} Server error: ${error.message}`; + case 'redirect': + return `${prefix} Redirect failed: ${error.message}`; + default: + return `${prefix} Network error: ${error.message}`; + } +} + +/** Check if error is retryable */ +export function isRetryableError(error: Error): boolean { + const type = categorizeError(error); + // Retry socket errors, timeouts, and unknown errors + if (type === 'socket' || type === 'timeout' || type === 'unknown') { + return true; + } + if (type === 'http') { + const msg = error.message; + // Retry 5xx server errors and 429 rate limit (HTTP 5xx matches 500, 502, 503, 504, etc.) + return msg.includes('HTTP 5') || msg.includes('HTTP 429'); + } + return false; +} + /** * Download file from URL with progress tracking + * @param timeout Timeout in ms (default 120000 for large files) */ export function downloadFile( url: string, destPath: string, onProgress?: ProgressCallback, - verbose = false + verbose = false, + timeout = 120000 ): Promise { return new Promise((resolve, reject) => { + let resolved = false; + + const cleanup = (err?: Error) => { + if (resolved) return; + resolved = true; + fs.unlink(destPath, () => {}); // Cleanup partial file + reject(err || new Error('Download aborted')); + }; + const handleResponse = (res: http.IncomingMessage) => { // Handle redirects (GitHub releases use 302) if (res.statusCode === 301 || res.statusCode === 302) { const redirectUrl = res.headers.location; if (!redirectUrl) { - reject(new Error('Redirect without location header')); + cleanup(new Error('Redirect without location header')); return; } if (verbose) { console.error(`[cliproxy] Following redirect: ${redirectUrl}`); } - downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject); + downloadFile(redirectUrl, destPath, onProgress, verbose, timeout) + .then(resolve) + .catch(reject); return; } if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + cleanup(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } @@ -70,58 +142,94 @@ export function downloadFile( res.pipe(fileStream); fileStream.on('finish', () => { + if (resolved) return; + resolved = true; fileStream.close(); resolve(); }); - fileStream.on('error', (err) => { - fs.unlink(destPath, () => {}); // Cleanup partial file - reject(err); - }); - - res.on('error', (err) => { - fs.unlink(destPath, () => {}); - reject(err); - }); + fileStream.on('error', cleanup); + res.on('error', cleanup); }; const protocol = url.startsWith('https') ? https : http; - const req = protocol.get(url, handleResponse); - req.on('error', reject); - req.setTimeout(60000, () => { + // Use agent: false to prevent connection pooling (allows process to exit) + const options = { + headers: { + 'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0', + }, + agent: false, // Disable connection pooling for clean exit + }; + + const req = protocol.get(url, options, handleResponse); + + req.on('error', (err) => { + if (!resolved) { + cleanup(err); + } + }); + + req.setTimeout(timeout, () => { req.destroy(); - reject(new Error('Download timeout (60s)')); + if (!resolved) { + cleanup(new Error(`Download timeout (${timeout / 1000}s)`)); + } }); }); } /** * Download file with retry logic and exponential backoff + * Uses smarter backoff for socket errors (longer delays) */ export async function downloadWithRetry( url: string, destPath: string, config: Partial = {} ): Promise { - const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config }; - let lastError = ''; + const { maxRetries, verbose, timeout } = { ...DEFAULT_CONFIG, ...config }; + let lastError: Error | null = null; let retries = 0; + let currentTimeout = timeout || 120000; while (retries < maxRetries) { try { - await downloadFile(url, destPath, undefined, verbose); + await downloadFile(url, destPath, undefined, verbose, currentTimeout); return { success: true, filePath: destPath, retries }; } catch (error) { const err = error as Error; - lastError = err.message; + lastError = err; retries++; - if (retries < maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, retries - 1) * 1000; + // Check if error is retryable + if (!isRetryableError(err)) { if (verbose) { - console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`); + console.error(`[cliproxy] Non-retryable error: ${err.message}`); + } + break; + } + + if (retries < maxRetries) { + const errorType = categorizeError(err); + // Socket errors: longer backoff (2s, 4s, 8s, 16s, 32s) + // Timeout errors: increase timeout and use standard backoff + // Other errors: standard exponential backoff (1s, 2s, 4s...) + let delay: number; + + if (errorType === 'socket') { + delay = Math.pow(2, retries) * 1000; // 2s, 4s, 8s, 16s, 32s + } else if (errorType === 'timeout') { + delay = Math.pow(2, retries - 1) * 1000; + currentTimeout = Math.min(currentTimeout * 1.5, 300000); // Increase timeout up to 5 min + } else { + delay = Math.pow(2, retries - 1) * 1000; + } + + // Log with user-friendly message + console.error(`[cliproxy] ${getErrorMessage(err, retries, maxRetries)}`); + if (verbose) { + console.error(`[cliproxy] Waiting ${delay}ms before retry...`); } await sleep(delay); } @@ -130,16 +238,18 @@ export async function downloadWithRetry( return { success: false, - error: `Download failed after ${retries} attempts: ${lastError}`, + error: `Download failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`, retries, }; } /** - * Fetch text content from URL + * Fetch text content from URL (single attempt) */ -export function fetchText(url: string, verbose = false): Promise { +function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise { return new Promise((resolve, reject) => { + let resolved = false; + const handleResponse = (res: http.IncomingMessage) => { // Handle redirects if (res.statusCode === 301 || res.statusCode === 302) { @@ -148,7 +258,7 @@ export function fetchText(url: string, verbose = false): Promise { reject(new Error('Redirect without location header')); return; } - fetchText(redirectUrl, verbose).then(resolve).catch(reject); + fetchTextOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject); return; } @@ -159,30 +269,90 @@ export function fetchText(url: string, verbose = false): Promise { let data = ''; res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve(data)); - res.on('error', reject); + res.on('end', () => { + if (!resolved) { + resolved = true; + resolve(data); + } + }); + res.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); }; const protocol = url.startsWith('https') ? https : http; - const req = protocol.get(url, handleResponse); - req.on('error', reject); - req.setTimeout(30000, () => { + const options = { + headers: { + 'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0', + }, + agent: false, // Disable connection pooling for clean exit + }; + + const req = protocol.get(url, options, handleResponse); + req.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + req.setTimeout(timeout, () => { req.destroy(); - reject(new Error('Request timeout (30s)')); + if (!resolved) { + resolved = true; + reject(new Error(`Request timeout (${timeout / 1000}s)`)); + } }); }); } /** - * Fetch JSON from URL (for GitHub API) + * Fetch text content from URL with retry logic */ -export function fetchJson(url: string, verbose = false): Promise> { +export async function fetchText(url: string, verbose = false, maxRetries = 3): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fetchTextOnce(url, verbose); + } catch (error) { + const err = error as Error; + lastError = err; + + if (!isRetryableError(err) || attempt === maxRetries) { + break; + } + + const delay = Math.pow(2, attempt - 1) * 1000; + if (verbose) { + console.error(`[cliproxy] fetchText retry ${attempt}/${maxRetries}: ${err.message}`); + } + await sleep(delay); + } + } + + throw lastError || new Error('fetchText failed'); +} + +/** + * Fetch JSON from URL (single attempt, for GitHub API) + */ +function fetchJsonOnce( + url: string, + verbose = false, + timeout = 15000 +): Promise> { return new Promise((resolve, reject) => { - const options = { + let resolved = false; + + const options: https.RequestOptions = { headers: { 'User-Agent': 'CCS-CLIProxyPlus-Updater/1.0', Accept: 'application/vnd.github.v3+json', }, + agent: false, // Disable connection pooling for clean exit }; const handleResponse = (res: http.IncomingMessage) => { @@ -192,7 +362,7 @@ export function fetchJson(url: string, verbose = false): Promise (data += chunk)); res.on('end', () => { + if (resolved) return; + resolved = true; try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid JSON from GitHub API')); } }); - res.on('error', reject); + res.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); }; const req = https.get(url, options, handleResponse); - req.on('error', reject); - req.setTimeout(10000, () => { + req.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + req.setTimeout(timeout, () => { req.destroy(); - reject(new Error('GitHub API timeout (10s)')); + if (!resolved) { + resolved = true; + reject(new Error(`GitHub API timeout (${timeout / 1000}s)`)); + } }); }); } +/** + * Fetch JSON from URL (for GitHub API) with retry logic + */ +export async function fetchJson( + url: string, + verbose = false, + maxRetries = 3 +): Promise> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fetchJsonOnce(url, verbose); + } catch (error) { + const err = error as Error; + lastError = err; + + if (!isRetryableError(err) || attempt === maxRetries) { + break; + } + + const delay = Math.pow(2, attempt - 1) * 1000; + if (verbose) { + console.error(`[cliproxy] GitHub API retry ${attempt}/${maxRetries}: ${err.message}`); + } + await sleep(delay); + } + } + + throw lastError || new Error('fetchJson failed'); +} + /** * Sleep helper */ diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 16e08434..6152be27 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -424,13 +424,17 @@ export async function execClaudeWithCLIProxy( if (proxyStatus.running && proxyStatus.verified) { // Healthy proxy found - join it if (proxyStatus.pid) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined; + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; } if (sessionId) { console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`)); } else { - // Failed to register session, but proxy is running - continue anyway - log(`Failed to register session but proxy is healthy, continuing...`); + // Failed to register session - proxy is running but we can't track it + // This happens when port-process detection fails (permissions, platform issues) + console.log( + info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`) + ); + log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`); } return; // Exit lock early, skip spawning } @@ -441,7 +445,7 @@ export async function execClaudeWithCLIProxy( const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout); if (becameHealthy) { if (proxyStatus.pid) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined; + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; } console.log(info(`Joined CLIProxy after startup wait`)); return; // Exit lock early @@ -459,7 +463,7 @@ export async function execClaudeWithCLIProxy( // Last resort: try HTTP health check (handles Windows PID-XXXXX case) const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000); if (isActuallyOurs) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid) ?? undefined; + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined; console.log(info(`Reclaimed CLIProxy with unrecognized process name`)); return; } diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index b2afe545..1a7bc812 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -73,12 +73,25 @@ export async function detectRunningProxy( // Proxy is running and responsive // Try to get PID from session lock if available const lock = getExistingProxy(port); - log(`HTTP check passed, proxy healthy (PID: ${lock?.pid ?? 'unknown'})`); + let pid = lock?.pid; + + // If no PID from session lock, try port-process detection + // This handles orphaned proxies (running but no sessions.json) + if (!pid) { + log('No PID from session lock, checking port process...'); + const portProcess = await getPortProcess(port); + if (portProcess) { + pid = portProcess.pid; + log(`Got PID from port process: ${pid}`); + } + } + + log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`); return { running: true, verified: true, method: 'http', - pid: lock?.pid, + pid, sessionCount: lock?.sessions?.length, }; }