diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index db03c28ec36..b9cbd851dd5 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -58,6 +58,8 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; import { writeStderrLine } from './utils/stdioHelpers.js'; import { computeWindowTitle } from './utils/windowTitle.js'; +import { preconnectApi } from './utils/apiPreconnect.js'; +import { startEarlyInputCapture } from './utils/earlyInputCapture.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; @@ -212,6 +214,7 @@ export async function startInteractiveUI( export async function main() { setupUnhandledRejectionHandler(); const settings = loadSettings(); + await cleanupCheckpoints(); let argv = await parseArguments(); @@ -366,6 +369,19 @@ export async function main() { // This ensures MCP server subprocesses are properly terminated on exit registerCleanup(() => config.shutdown()); + // Startup optimization: preconnect API to warm TCP+TLS connection + // Only fire for flows that will make API calls + try { + const authType = config.getModelsConfig().getCurrentAuthType(); + preconnectApi(authType, { + settingsBaseUrl: settings.merged.security?.auth?.baseUrl as + | string + | undefined, + }); + } catch { + // If we can't get authType, skip preconnect - it's optional optimization + } + // FIXME: list extensions after the config initialize // if (config.getListExtensions()) { // console.log('Installed extensions:'); @@ -382,6 +398,9 @@ export async function main() { // input showing up in the output. process.stdin.setRawMode(true); + // Startup optimization: start early input capture + startEarlyInputCapture(); + // This cleanup isn't strictly needed but may help in certain situations. process.on('SIGTERM', () => { process.stdin.setRawMode(wasRaw); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index f145cdc6ddb..47c669613ea 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -39,6 +39,10 @@ import { import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; +import { + stopEarlyInputCapture, + getAndClearCapturedInput, +} from '../../utils/earlyInputCapture.js'; const ESC = '\u001B'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; @@ -158,6 +162,10 @@ export function KeypressProvider({ setRawMode(true); } + // Startup optimization: stop early input capture and get captured input + stopEarlyInputCapture(); + const capturedInput = getAndClearCapturedInput(); + const keypressStream = new PassThrough(); let usePassthrough = false; // Use passthrough mode when pasteWorkaround is enabled, @@ -985,6 +993,22 @@ export function KeypressProvider({ stdin.on('keypress', handleKeypress); } + // Startup optimization: replay captured input if available + if (capturedInput.length > 0) { + debugLogger.debug( + `Replaying ${capturedInput.length} bytes of captured input`, + ); + // Process in next event loop tick to ensure subscribers are ready + setImmediate(() => { + if (usePassthrough) { + keypressStream.write(capturedInput); + } else { + // Emit data event directly on stdin + stdin.emit('data', capturedInput); + } + }); + } + return () => { if (usePassthrough) { keypressStream.removeListener('keypress', handleKeypress); diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts new file mode 100644 index 00000000000..0d81d17ae58 --- /dev/null +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { preconnectApi, resetPreconnectState } from './apiPreconnect.js'; + +// Mock fetch +const mockFetch = vi.fn().mockResolvedValue(undefined); +global.fetch = mockFetch; + +describe('apiPreconnect', () => { + beforeEach(() => { + resetPreconnectState(); + mockFetch.mockClear(); + mockFetch.mockResolvedValue(undefined); + delete process.env['HTTPS_PROXY']; + delete process.env['https_proxy']; + delete process.env['HTTP_PROXY']; + delete process.env['http_proxy']; + delete process.env['OPENAI_BASE_URL']; + delete process.env['ANTHROPIC_BASE_URL']; + delete process.env['GEMINI_BASE_URL']; + delete process.env['QWEN_CODE_DISABLE_PRECONNECT']; + delete process.env['NODE_EXTRA_CA_CERTS']; + delete process.env['SANDBOX']; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('shouldSkipPreconnect', () => { + it('should skip when HTTPS_PROXY is set', () => { + process.env['HTTPS_PROXY'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when https_proxy is set', () => { + process.env['https_proxy'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when HTTP_PROXY is set', () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when http_proxy is set', () => { + process.env['http_proxy'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when NODE_EXTRA_CA_CERTS is set', () => { + process.env['NODE_EXTRA_CA_CERTS'] = '/path/to/ca.pem'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when custom baseUrl is set', () => { + preconnectApi('openai', { settingsBaseUrl: 'https://custom.api.com/v1' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should not skip when baseUrl is a default URL', () => { + preconnectApi('openai', { settingsBaseUrl: 'https://api.openai.com/v1' }); + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + describe('preconnect behavior', () => { + it('should use default baseUrl for qwen-oauth', () => { + preconnectApi('qwen-oauth'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://coding.dashscope.aliyuncs.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should use default baseUrl for openai', () => { + preconnectApi('openai'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should use default baseUrl for anthropic', () => { + preconnectApi('anthropic'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should use settings baseUrl when available', () => { + preconnectApi('openai', { + settingsBaseUrl: 'https://custom.openai.com/v1', + }); + // Should skip because it's a custom URL + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use environment variable baseUrl when available', () => { + process.env['OPENAI_BASE_URL'] = 'https://custom.env.com/v1'; + preconnectApi('openai'); + // Should skip because it's a custom URL + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should only check OPENAI_BASE_URL for openai authType', () => { + process.env['OPENAI_BASE_URL'] = 'https://api.openai.com/v1'; + process.env['ANTHROPIC_BASE_URL'] = 'https://custom.anthropic.com/v1'; + preconnectApi('openai'); + // Should use OPENAI_BASE_URL (which is default), ignore ANTHROPIC_BASE_URL + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should only check ANTHROPIC_BASE_URL for anthropic authType', () => { + process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com'; + process.env['OPENAI_BASE_URL'] = 'https://custom.openai.com/v1'; + preconnectApi('anthropic'); + // Should use ANTHROPIC_BASE_URL (which is default), ignore OPENAI_BASE_URL + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should not fire twice', () => { + preconnectApi('qwen-oauth'); + preconnectApi('openai'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch errors gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + // Should not throw + expect(() => preconnectApi('qwen-oauth')).not.toThrow(); + }); + + it('should skip when QWEN_CODE_DISABLE_PRECONNECT is set', () => { + process.env['QWEN_CODE_DISABLE_PRECONNECT'] = '1'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip in sandbox mode', () => { + process.env['SANDBOX'] = '1'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts new file mode 100644 index 00000000000..c11462480a0 --- /dev/null +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * API Preconnect - Warm API connections to reduce TCP+TLS handshake latency + * + * Principle: Fire a fire-and-forget HEAD request early in startup to warm + * the TCP+TLS connection. Subsequent actual API calls reuse this connection, + * saving 100-200ms. + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('PRECONNECT'); + +let preconnectFired = false; + +/** + * Default API base URLs by AuthType + */ +const DEFAULT_BASE_URLS: Record = { + openai: 'https://api.openai.com', + 'qwen-oauth': 'https://coding.dashscope.aliyuncs.com', + anthropic: 'https://api.anthropic.com', + gemini: 'https://generativelanguage.googleapis.com', + 'vertex-ai': 'https://us-central1-aiplatform.googleapis.com', +}; + +/** + * Check if preconnect should be skipped + */ +function shouldSkipPreconnect(settings: { baseUrl?: string }): boolean { + // 1. Check proxy environment variables + // Note: If NO_PROXY is set and target URL is in it, we don't need to skip + // But for simplicity: skip if any proxy config is present + if ( + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'] + ) { + debugLogger.debug('Skipping preconnect: proxy environment variable set'); + return true; + } + + // 2. Check custom CA certificate (may use enterprise TLS inspection) + if (process.env['NODE_EXTRA_CA_CERTS']) { + debugLogger.debug('Skipping preconnect: custom CA certificate configured'); + return true; + } + + // 3. User explicitly configured custom baseUrl (may use mTLS or private deployment) + if (settings.baseUrl && !isDefaultBaseUrl(settings.baseUrl)) { + debugLogger.debug( + 'Skipping preconnect: custom baseUrl (may use mTLS or private deployment)', + ); + return true; + } + + return false; +} + +/** + * Check if running in sandbox mode + * In sandbox mode, preconnect is ineffective because the process will restart + */ +function isInSandboxMode(): boolean { + return process.env['SANDBOX'] !== undefined; +} + +/** + * Check if baseUrl is a default URL + */ +function isDefaultBaseUrl(baseUrl: string): boolean { + const normalized = baseUrl.toLowerCase().replace(/\/+$/, ''); + return Object.values(DEFAULT_BASE_URLS).some((url) => + normalized.startsWith(url.toLowerCase()), + ); +} + +/** + * Environment variable to AuthType mapping + */ +const ENV_BASE_URL_MAP: Record = { + OPENAI_BASE_URL: 'openai', + ANTHROPIC_BASE_URL: 'anthropic', + GEMINI_BASE_URL: 'gemini', +}; + +/** + * Get environment variable baseUrl for the given authType + */ +function getEnvBaseUrlForAuthType( + authType: string | undefined, +): string | undefined { + if (!authType) { + return undefined; + } + + // Lookup the corresponding environment variable based on authType + for (const [envVar, mappedAuthType] of Object.entries(ENV_BASE_URL_MAP)) { + if (mappedAuthType === authType) { + return process.env[envVar]; + } + } + + return undefined; +} + +/** + * Get the target URL for preconnect + * Priority: settingsBaseUrl > environment variable > default value + * + * If custom baseUrl is set (non-default URL), return undefined to skip preconnect + */ +function getPreconnectTargetUrl( + authType: string | undefined, + settingsBaseUrl: string | undefined, +): string | undefined { + // 1. Get from settings + if (settingsBaseUrl) { + // If it's a default URL, use it; otherwise skip + if (isDefaultBaseUrl(settingsBaseUrl)) { + return settingsBaseUrl; + } + return undefined; + } + + // 2. Get from environment variable (lookup based on authType) + const envBaseUrl = getEnvBaseUrlForAuthType(authType); + if (envBaseUrl) { + // If it's a default URL, use it; otherwise skip + if (isDefaultBaseUrl(envBaseUrl)) { + return envBaseUrl; + } + return undefined; + } + + // 3. Use default value + if (authType && DEFAULT_BASE_URLS[authType]) { + return DEFAULT_BASE_URLS[authType]; + } + + return undefined; +} + +/** + * Execute API preconnect + * Use HEAD request to establish TCP+TLS connection without sending actual request body + * + * @param authType - Authentication type (openai, qwen-oauth, anthropic, etc.) + * @param options - Configuration options + */ +export function preconnectApi( + authType: string | undefined, + options: { + settingsBaseUrl?: string; + } = {}, +): void { + if (preconnectFired) { + return; + } + preconnectFired = true; + + // Check if disabled + if (process.env['QWEN_CODE_DISABLE_PRECONNECT'] === '1') { + debugLogger.debug('Preconnect disabled by environment variable'); + return; + } + + // Check if in sandbox mode (process will restart, preconnect is ineffective) + if (isInSandboxMode()) { + debugLogger.debug('Skipping preconnect: sandbox mode detected'); + return; + } + + // Check skip conditions + if ( + shouldSkipPreconnect({ + baseUrl: options.settingsBaseUrl, + }) + ) { + return; + } + + const targetUrl = getPreconnectTargetUrl(authType, options.settingsBaseUrl); + + if (!targetUrl) { + debugLogger.debug('No target URL for preconnect'); + return; + } + + debugLogger.debug(`Preconnecting to: ${targetUrl}`); + + // Fire HEAD request to warm connection (fire-and-forget) + fetch(targetUrl, { + method: 'HEAD', + signal: AbortSignal.timeout(5_000), + // Don't send any authentication info + headers: { + 'User-Agent': 'QwenCode-Preconnect/1.0', + }, + }) + .then(() => { + debugLogger.debug('Preconnect completed'); + }) + .catch((error) => { + // Preconnect failure doesn't affect main flow + debugLogger.debug(`Preconnect failed (ignored): ${error}`); + }); +} + +/** + * Reset preconnect state (for testing only) + */ +export function resetPreconnectState(): void { + preconnectFired = false; +} diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts new file mode 100644 index 00000000000..a3678a98810 --- /dev/null +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + startEarlyInputCapture, + stopEarlyInputCapture, + getAndClearCapturedInput, + hasCapturedInput, + resetCaptureState, +} from './earlyInputCapture.js'; +import { PassThrough } from 'node:stream'; + +describe('earlyInputCapture', () => { + let mockStdin: PassThrough; + let originalStdin: typeof process.stdin; + let originalIsTTY: boolean; + + beforeEach(() => { + resetCaptureState(); + + // Save original stdin + originalStdin = process.stdin; + originalIsTTY = process.stdin.isTTY ?? false; + + // Create mock stdin + mockStdin = new PassThrough(); + Object.defineProperty(process, 'stdin', { + value: mockStdin, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + + delete process.env['QWEN_CODE_DISABLE_EARLY_CAPTURE']; + }); + + afterEach(() => { + resetCaptureState(); + + // Restore original stdin + Object.defineProperty(process, 'stdin', { + value: originalStdin, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + writable: true, + configurable: true, + }); + }); + + describe('capture lifecycle', () => { + it('should start and stop capture correctly', () => { + startEarlyInputCapture(); + expect(hasCapturedInput()).toBe(false); + + mockStdin.write(Buffer.from('a')); + expect(hasCapturedInput()).toBe(true); + + stopEarlyInputCapture(); + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('a'); + }); + + it('should not capture after stop', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + mockStdin.write(Buffer.from('b')); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('a'); + }); + + it('should not start capture if not TTY', () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false }); + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + + expect(hasCapturedInput()).toBe(false); + }); + + it('should not start capture twice', () => { + startEarlyInputCapture(); + startEarlyInputCapture(); // Second call should be ignored + + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('a'); + }); + }); + + describe('terminal response filtering', () => { + it('should filter DEC private mode responses (ESC [ ?)', () => { + startEarlyInputCapture(); + // DEC private mode response: ESC [ ? 1 0 0 4 h + mockStdin.write(Buffer.from('\x1b[?1004h')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter DA2 responses (ESC [ >)', () => { + startEarlyInputCapture(); + // DA2 response: ESC [ > 0 ; 9 5 ; 0 c + mockStdin.write(Buffer.from('\x1b[>0;95;0c')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter OSC sequences (ESC ])', () => { + startEarlyInputCapture(); + // OSC sequence: ESC ] 0 ; title BEL + mockStdin.write(Buffer.from('\x1b]0;window title\x07')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter DCS sequences (ESC P)', () => { + startEarlyInputCapture(); + // DCS sequence: ESC P ... ST + mockStdin.write(Buffer.from('\x1bP$data\x1b\\')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should keep user input mixed with terminal responses', () => { + startEarlyInputCapture(); + // Mix of user input and terminal response + mockStdin.write(Buffer.from('a\x1b[?1004hb\x1b]0;title\x07c')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('abc'); + }); + + it('should keep arrow key sequences (user input)', () => { + startEarlyInputCapture(); + // Arrow up: ESC [ A (this is a user input, not terminal response) + mockStdin.write(Buffer.from('\x1b[A')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + // Arrow key sequence should be kept (it's user input) + expect(input.toString()).toBe('\x1b[A'); + }); + + it('should keep function key sequences (user input)', () => { + startEarlyInputCapture(); + // F1: ESC O P + mockStdin.write(Buffer.from('\x1bOP')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('\x1bOP'); + }); + }); + + describe('UTF-8 handling', () => { + it('should capture simple ASCII characters', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('abc')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('abc'); + }); + + it('should capture UTF-8 multibyte characters', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('你好世界')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('你好世界'); + }); + + it('should capture emoji', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('👋🎉')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('👋🎉'); + }); + }); + + describe('edge cases', () => { + it('should handle empty input', () => { + startEarlyInputCapture(); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should clear captured input after getAndClearCapturedInput', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('test')); + stopEarlyInputCapture(); + + const input1 = getAndClearCapturedInput(); + expect(input1.toString()).toBe('test'); + + const input2 = getAndClearCapturedInput(); + expect(input2.length).toBe(0); + }); + + it('should skip when QWEN_CODE_DISABLE_EARLY_CAPTURE is set', () => { + process.env['QWEN_CODE_DISABLE_EARLY_CAPTURE'] = '1'; + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + + expect(hasCapturedInput()).toBe(false); + }); + + it('should limit buffer size', () => { + startEarlyInputCapture(); + + // Write more than 64KB + const largeData = Buffer.alloc(100 * 1024, 'a'); + mockStdin.write(largeData); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + // Should be truncated to 64KB + expect(input.length).toBeLessThanOrEqual(64 * 1024); + }); + }); +}); diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts new file mode 100644 index 00000000000..a374cb73166 --- /dev/null +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Early Input Capture - Capture user input during REPL initialization + * + * Principle: Start raw mode stdin listening at the earliest CLI entry point, + * then inject buffered content when REPL is ready. Solves the problem of + * user input being lost during startup. + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('EARLY_INPUT'); + +/** Maximum buffer size (64KB) */ +const MAX_BUFFER_SIZE = 64 * 1024; + +/** + * Input buffer + */ +interface InputBuffer { + /** Raw byte data */ + rawBytes: Buffer; + /** Whether capture is complete */ + captured: boolean; +} + +let inputBuffer: InputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, +}; + +let captureHandler: ((data: Buffer) => void) | null = null; +let isCapturing = false; + +/** + * Check if this is a terminal response sequence + * Terminal responses typically start with specific prefixes + * + * Note: User input function key sequences should be preserved: + * - ESC [ A/B/C/D - Arrow keys + * - ESC O P/Q/R/S - F1-F4 (SS3 sequences) + * - ESC [ 1;5A - Ctrl+arrow and other modified keys + */ +function isTerminalResponse(data: Buffer, startIdx: number): boolean { + if (startIdx >= data.length || data[startIdx] !== 0x1b) { + return false; + } + + const nextIdx = startIdx + 1; + if (nextIdx >= data.length) { + return false; + } + + const nextByte = data[nextIdx]; + + // Check for special characters directly after ESC + // P = 0x50 (DCS), _ = 0x5F (APC), ^ = 0x5E (PM), ] = 0x5D (OSC) + // Note: O = 0x4F is SS3 sequence for function keys, should be preserved + if ( + nextByte === 0x50 || // P (DCS) + nextByte === 0x5f || // _ (APC) + nextByte === 0x5e || // ^ (PM) + nextByte === 0x5d // ] (OSC) + ) { + return true; + } + + // Check for terminal responses in CSI sequences + // ESC [ ? ... (DEC private mode response) + // ESC [ > ... (DA2 response) + if (nextByte === 0x5b) { + // CSI sequence, check third character + const thirdIdx = startIdx + 2; + if (thirdIdx < data.length) { + const thirdByte = data[thirdIdx]; + if (thirdByte === 0x3f || thirdByte === 0x3e) { + // ESC [ ? or ESC [ > - this is a terminal response + return true; + } + } + } + + return false; +} + +/** + * Skip terminal response sequence + * Returns the index position after skipping + */ +function skipTerminalResponse(data: Buffer, startIdx: number): number { + if (startIdx >= data.length || data[startIdx] !== 0x1b) { + return startIdx + 1; + } + + const nextIdx = startIdx + 1; + if (nextIdx >= data.length) { + return nextIdx; + } + + const nextByte = data[nextIdx]; + + // OSC sequence: ESC ] ... BEL or ESC ] ... ST + if (nextByte === 0x5d) { + let i = startIdx + 2; + while (i < data.length) { + // BEL (0x07) or ST (ESC \) + if (data[i] === 0x07) { + return i + 1; + } + if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { + return i + 2; + } + i++; + } + return data.length; + } + + // DCS/APC/PM sequences: ESC P/_/^ ... ST + if (nextByte === 0x50 || nextByte === 0x5f || nextByte === 0x5e) { + let i = startIdx + 2; + while (i < data.length) { + // ST (ESC \) + if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { + return i + 2; + } + i++; + } + return data.length; + } + + // CSI sequence: ESC [ ... (ends with 0x40-0x7E) + if (nextByte === 0x5b) { + let i = startIdx + 2; + while (i < data.length) { + const byte = data[i]; + // CSI sequences end with 0x40-0x7E + if (byte >= 0x40 && byte <= 0x7e) { + return i + 1; + } + i++; + } + return data.length; + } + + return startIdx + 1; +} + +/** + * Filter terminal response sequences (like Kitty protocol responses, device attributes, etc.) + * Preserve user input (including function keys like arrow keys) + */ +function filterTerminalResponses(data: Buffer): Buffer { + const result: number[] = []; + let i = 0; + + while (i < data.length) { + // Detect ESC sequences + if (data[i] === 0x1b) { + // Check if this is a terminal response (should be filtered out) + if (isTerminalResponse(data, i)) { + // Skip the terminal response sequence + i = skipTerminalResponse(data, i); + continue; + } + // User input function keys (like arrow keys ESC [A), preserve + } + // Preserve current byte + result.push(data[i]); + i++; + } + + return Buffer.from(result); +} + +/** + * Start early input capture + * Call immediately after setting raw mode in gemini.tsx + */ +export function startEarlyInputCapture(): void { + if (isCapturing || !process.stdin.isTTY) { + return; + } + + // Check if disabled + if (process.env['QWEN_CODE_DISABLE_EARLY_CAPTURE'] === '1') { + debugLogger.debug('Early input capture disabled by environment variable'); + return; + } + + isCapturing = true; + inputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, + }; + + debugLogger.debug('Starting early input capture'); + + captureHandler = (data: Buffer) => { + if (inputBuffer.captured) { + return; + } + + // Check buffer size limit + if (inputBuffer.rawBytes.length >= MAX_BUFFER_SIZE) { + debugLogger.debug('Buffer size limit reached, stopping capture'); + return; + } + + // Filter out terminal response sequences (like Kitty protocol responses) + const filtered = filterTerminalResponses(data); + if (filtered.length > 0) { + // Limit buffer size + const newLength = inputBuffer.rawBytes.length + filtered.length; + if (newLength > MAX_BUFFER_SIZE) { + const truncated = filtered.subarray( + 0, + MAX_BUFFER_SIZE - inputBuffer.rawBytes.length, + ); + inputBuffer.rawBytes = Buffer.concat([inputBuffer.rawBytes, truncated]); + debugLogger.debug(`Buffer truncated at ${MAX_BUFFER_SIZE} bytes`); + } else { + inputBuffer.rawBytes = Buffer.concat([inputBuffer.rawBytes, filtered]); + debugLogger.debug( + `Captured ${filtered.length} bytes (total: ${inputBuffer.rawBytes.length})`, + ); + } + } + }; + + process.stdin.on('data', captureHandler); +} + +/** + * Stop early input capture + * Call before KeypressProvider mounts + */ +export function stopEarlyInputCapture(): void { + if (!isCapturing || !captureHandler) { + return; + } + + process.stdin.removeListener('data', captureHandler); + captureHandler = null; + isCapturing = false; + inputBuffer.captured = true; + + debugLogger.debug( + `Stopped early input capture: ${inputBuffer.rawBytes.length} bytes`, + ); +} + +/** + * Get and clear captured input + * For use by KeypressContext + */ +export function getAndClearCapturedInput(): Buffer { + const buffer = Buffer.from(inputBuffer.rawBytes); + inputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, + }; + return buffer; +} + +/** + * Check if there is captured input + */ +export function hasCapturedInput(): boolean { + return inputBuffer.rawBytes.length > 0; +} + +/** + * Reset capture state (for testing only) + */ +export function resetCaptureState(): void { + if (captureHandler) { + process.stdin.removeListener('data', captureHandler); + captureHandler = null; + } + isCapturing = false; + inputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, + }; +} diff --git a/scripts/benchmark-api-latency.sh b/scripts/benchmark-api-latency.sh new file mode 100755 index 00000000000..9a9c74c6d53 --- /dev/null +++ b/scripts/benchmark-api-latency.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# First API Call Latency Benchmark +# Measures the impact of API Preconnect on first API call latency + +set -e + +ITERATIONS=${ITERATIONS:-5} +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI_PATH="${CLI_PATH:-"$SCRIPT_DIR/../dist/cli.js"}" + +echo "=== Qwen Code First API Call Latency Benchmark ===" +echo "This test measures the time for the first API call" +echo "Iterations: $ITERATIONS" +echo "" + +# Create temp directory +RESULTS_DIR=$(mktemp -d) +trap "rm -rf $RESULTS_DIR" EXIT + +# Simulate API call latency test +# Use curl to measure actual TCP+TLS handshake time +TARGET_URL="https://coding.dashscope.aliyuncs.com" + +echo "Test: TCP+TLS Handshake Time Comparison" +echo "" + +echo "1. Without Preconnect (cold connection):" +echo "Cold Handshake" > "$RESULTS_DIR/cold.txt" +for i in $(seq 1 $ITERATIONS); do + # Wait for connection to close + sleep 2 + + # Measure connection time + # Note: time_appconnect is the total time from start until TLS handshake is done + # (it already includes time_connect), so use it directly as total handshake time. + # TLS-only time = time_appconnect - time_connect + result=$(curl -o /dev/null -s -w "%{time_connect}:%{time_appconnect}" "$TARGET_URL" 2>&1 || echo "0:0") + connect_time=$(echo "$result" | cut -d: -f1) + appconnect_time=$(echo "$result" | cut -d: -f2) + tls_only=$(echo "$appconnect_time - $connect_time" | bc) + + echo "$appconnect_time" >> "$RESULTS_DIR/cold.txt" + echo " Run $i: tcp=${connect_time}s, tls_only=${tls_only}s, total=${appconnect_time}s" +done + +echo "" +echo "2. With Preconnect (warm connection):" +echo "Warm Handshake" > "$RESULTS_DIR/warm.txt" +for i in $(seq 1 $ITERATIONS); do + # Preconnect + curl -o /dev/null -s -X HEAD "$TARGET_URL" 2>/dev/null || true + + # Measure connection time (should reuse connection) + result=$(curl -o /dev/null -s -w "%{time_connect}:%{time_appconnect}" "$TARGET_URL" 2>&1 || echo "0:0") + connect_time=$(echo "$result" | cut -d: -f1) + appconnect_time=$(echo "$result" | cut -d: -f2) + tls_only=$(echo "$appconnect_time - $connect_time" | bc) + + echo "$appconnect_time" >> "$RESULTS_DIR/warm.txt" + echo " Run $i: tcp=${connect_time}s, tls_only=${tls_only}s, total=${appconnect_time}s" + + sleep 2 +done + +# Calculate statistics +echo "" +echo "=== Results ===" +echo "" + +python3 - < 0 else 0 + + print("Cold Connection (without preconnect):") + print(f" Mean: {mean_cold:.3f}s ({mean_cold*1000:.1f}ms)") + print("") + print("Warm Connection (with preconnect):") + print(f" Mean: {mean_warm:.3f}s ({mean_warm*1000:.1f}ms)") + print("") + print(f"Improvement: {improvement:.1f}ms ({improvement_percent:.1f}%)") + print("") + + if improvement_percent >= 50: + print("SUCCESS: Connection reuse is working effectively!") + print(" Preconnect successfully reduces TCP+TLS handshake time") + elif improvement_percent >= 20: + print("GOOD: Significant improvement in connection time") + else: + print("Note: Results may vary based on network conditions") + print("") + print("| Scenario | Mean Time |") + print("|----------|-----------|") + print(f"| Cold (no preconnect) | {mean_cold*1000:.1f}ms |") + print(f"| Warm (with preconnect) | {mean_warm*1000:.1f}ms |") + print(f"| Improvement | {improvement:.1f}ms ({improvement_percent:.1f}%) |") +else: + print("No valid data collected") +EOF diff --git a/scripts/benchmark-startup-simple.sh b/scripts/benchmark-startup-simple.sh new file mode 100755 index 00000000000..a11f7b9e55b --- /dev/null +++ b/scripts/benchmark-startup-simple.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Simple Startup Time Benchmark +# Measures baseline CLI startup time (module loading, initialization overhead). +# +# Note: This uses `--version` which exits before the preconnect code path. +# To measure the actual preconnect effect on TCP+TLS handshake time, +# use benchmark-api-latency.sh instead. + +set -e + +ITERATIONS=${ITERATIONS:-5} +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI_PATH="${CLI_PATH:-"$SCRIPT_DIR/../dist/cli.js"}" + +echo "=== Qwen Code Startup Time Benchmark ===" +echo "Iterations: $ITERATIONS" +echo "CLI Path: $CLI_PATH" +echo "" + +# Function: calculate statistics +calculate_stats() { + local file=$1 + local name=$2 + + if command -v python3 &> /dev/null; then + python3 - < "$RESULTS_DIR/startup.txt" + +for i in $(seq 1 $ITERATIONS); do + start=$(node -e "console.log(Date.now())") + node "$CLI_PATH" --version > /dev/null 2>&1 + end=$(node -e "console.log(Date.now())") + + elapsed=$((end - start)) + echo "$elapsed" >> "$RESULTS_DIR/startup.txt" + echo " Run $i: ${elapsed}ms" +done + +# Calculate statistics +echo "" +echo "=== Results ===" +echo "" + +calculate_stats "$RESULTS_DIR/startup.txt" "Startup Time (--version)" diff --git a/scripts/benchmark-startup.sh b/scripts/benchmark-startup.sh new file mode 100755 index 00000000000..e47f8c00c95 --- /dev/null +++ b/scripts/benchmark-startup.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Startup Performance Benchmark +# Measures baseline CLI startup time (module loading, initialization overhead). +# +# Note: This uses `--version` which exits before the preconnect code path. +# To measure the actual preconnect effect on TCP+TLS handshake time, +# use benchmark-api-latency.sh instead. + +set -e + +ITERATIONS=${ITERATIONS:-10} +RESULTS_DIR=$(mktemp -d) +trap "rm -rf $RESULTS_DIR" EXIT +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +CLI_CMD=${CLI_CMD:-"qwen"} + +echo "=== Qwen Code Startup Time Benchmark ===" +echo "Iterations: $ITERATIONS" +echo "Timestamp: $TIMESTAMP" +echo "CLI Command: $CLI_CMD" +echo "" + +# Function: calculate statistics +calculate_stats() { + local file=$1 + local name=$2 + + if command -v python3 &> /dev/null; then + python3 - < "$RESULTS_DIR/startup_$TIMESTAMP.txt" + +for i in $(seq 1 $ITERATIONS); do + start=$(node -e "console.log(Date.now())") + $CLI_CMD --version > /dev/null 2>&1 + end=$(node -e "console.log(Date.now())") + + elapsed=$((end - start)) + echo "$elapsed" >> "$RESULTS_DIR/startup_$TIMESTAMP.txt" + echo " Run $i: ${elapsed}ms" +done + +# Calculate statistics and output results +echo "" +echo "=== Results ===" +echo "" + +calculate_stats "$RESULTS_DIR/startup_$TIMESTAMP.txt" "Startup Time (--version)"