| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { spawn, execSync } from 'child_process'; |
| import { mkdirSync } from 'fs'; |
| import { existsSync } from 'fs'; |
| import http2 from 'http2'; |
| import net from 'net'; |
| import { randomUUID } from 'crypto'; |
| import { resolve } from 'path'; |
| import { log } from './config.js'; |
| import { closeSessionForPort } from './grpc.js'; |
|
|
| const DEFAULT_BINARY = '/opt/windsurf/language_server_linux_x64'; |
| const DEFAULT_PORT = 42100; |
| const DEFAULT_CSRF = 'windsurf-api-csrf-fixed-token'; |
| const DEFAULT_API_URL = 'https://server.self-serve.windsurf.com'; |
| const DEFAULT_LINUX_DATA_ROOT = '/opt/windsurf/data'; |
|
|
| |
| |
| |
| const MAX_LS_INSTANCES = (() => { |
| const n = parseInt(process.env.LS_MAX_INSTANCES || '', 10); |
| return Number.isFinite(n) && n > 0 ? n : 20; |
| })(); |
|
|
| |
| const AUTO_RESTART_ENABLED = process.env.LS_AUTO_RESTART !== '0'; |
| const AUTO_RESTART_MAX_RETRIES = (() => { |
| const n = parseInt(process.env.LS_AUTO_RESTART_MAX_RETRIES || '', 10); |
| return Number.isFinite(n) && n > 0 ? n : 3; |
| })(); |
| const AUTO_RESTART_BASE_DELAY_MS = (() => { |
| const n = parseInt(process.env.LS_AUTO_RESTART_BASE_DELAY_MS || '', 10); |
| return Number.isFinite(n) && n > 0 ? n : 1000; |
| })(); |
|
|
| |
| const _pool = new Map(); |
| |
| |
| |
| const _pending = new Map(); |
| |
| |
| |
| const _intentionalShutdown = new Set(); |
| let _nextPort = DEFAULT_PORT + 1; |
| let _binaryPath = DEFAULT_BINARY; |
| let _apiServerUrl = DEFAULT_API_URL; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const STICKY_USER_RE = /(?:[_-](?:sid|session|sessid|sticky|sess|token|res|rotating|sticky|ip[_-]?[0-9])|[+]ws_|^brd-customer-|^customer-|^user-|^res-|^sticky-|-zone-[a-z]+|-cc-[a-z]{2}|-country-|-state-|-city-|-session-|-sess-|-sticky-|-res-|-rotating-)/i; |
| function isStickyUsername(u) { |
| if (typeof u !== 'string' || u.length < 4) return false; |
| return STICKY_USER_RE.test(u); |
| } |
|
|
| function proxyKey(proxy) { |
| if (!proxy || !proxy.host) return 'default'; |
| |
| |
| |
| const safeHost = proxy.host.replace(/[^a-zA-Z0-9]/g, '_'); |
| const safePort = String(proxy.port || 8080).replace(/[^0-9]/g, ''); |
| let key = `px_${safeHost}_${safePort}`; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let segregateByUser = false; |
| if (process.env.WINDSURFAPI_LS_PER_PROXY_USER === '0') { |
| segregateByUser = false; |
| } else if (process.env.WINDSURFAPI_LS_PER_PROXY_USER === '1') { |
| segregateByUser = !!proxy.username; |
| } else if (proxy.username && isStickyUsername(proxy.username)) { |
| segregateByUser = true; |
| } |
| if (segregateByUser) { |
| |
| |
| |
| const safeUser = String(proxy.username).replace(/[^a-zA-Z0-9]/g, '_').slice(0, 32); |
| if (safeUser) key += `_u${safeUser}`; |
| } |
| return key; |
| } |
|
|
| export function defaultLsDataRoot(platform = process.platform, home = process.env.HOME) { |
| return platform === 'darwin' |
| ? resolve(home || '.', '.windsurf', 'data') |
| : DEFAULT_LINUX_DATA_ROOT; |
| } |
|
|
| function dataDirForKey(key) { |
| const root = process.env.LS_DATA_DIR |
| ? resolve(process.cwd(), process.env.LS_DATA_DIR) |
| : defaultLsDataRoot(); |
| return `${root}/${key}`; |
| } |
|
|
| function proxyUrl(proxy) { |
| if (!proxy || !proxy.host) return null; |
| const auth = proxy.username |
| ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || '')}@` |
| : ''; |
| return `http://${auth}${proxy.host}:${proxy.port || 8080}`; |
| } |
|
|
| |
| |
| |
| |
| |
| const LS_ENV_ALLOWLIST = [ |
| 'HOME', 'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'TMP', 'TEMP', |
| 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', |
| 'http_proxy', 'https_proxy', 'no_proxy', |
| |
| |
| 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', |
| ]; |
|
|
| export function buildLanguageServerEnv(source = process.env, options = {}) { |
| const env = {}; |
| for (const key of LS_ENV_ALLOWLIST) { |
| if (source[key] != null && source[key] !== '') env[key] = source[key]; |
| } |
| |
| |
| |
| if (!env.HOME) env.HOME = source.HOME || '/root'; |
| const pUrl = options.proxyUrl || null; |
| if (pUrl) { |
| env.HTTPS_PROXY = pUrl; |
| env.HTTP_PROXY = pUrl; |
| env.https_proxy = pUrl; |
| env.http_proxy = pUrl; |
| } |
| return env; |
| } |
|
|
| export function redactProxyUrl(urlOrProxy) { |
| if (!urlOrProxy) return 'none'; |
| if (typeof urlOrProxy === 'object') { |
| const host = urlOrProxy.host || ''; |
| const port = urlOrProxy.port || 8080; |
| return `${host}:${port}${urlOrProxy.username ? ' (auth=true)' : ''}`; |
| } |
| try { |
| const u = new URL(String(urlOrProxy)); |
| return `${u.hostname}:${u.port || (u.protocol === 'https:' ? '443' : '80')}${u.username || u.password ? ' (auth=true)' : ''}`; |
| } catch { |
| return String(urlOrProxy).replace(/\/\/([^:@/\s]+):([^@/\s]*)@/g, '//***:***@'); |
| } |
| } |
|
|
| function isPortInUse(port) { |
| return new Promise((resolve) => { |
| const sock = net.createConnection({ port, host: '127.0.0.1' }, () => { |
| sock.destroy(); resolve(true); |
| }); |
| sock.on('error', () => resolve(false)); |
| sock.setTimeout(1000, () => { sock.destroy(); resolve(false); }); |
| }); |
| } |
|
|
| export function probeLanguageServerPort(port, timeoutMs = 1500) { |
| return new Promise((resolve) => { |
| const client = http2.connect(`http://localhost:${port}`); |
| let settled = false; |
| let req = null; |
| const finish = (ok) => { |
| if (settled) return; |
| settled = true; |
| clearTimeout(timer); |
| try { req?.close(); } catch {} |
| try { client.close(); } catch {} |
| resolve(ok); |
| }; |
| const timer = setTimeout(() => finish(false), timeoutMs); |
| client.on('error', () => finish(false)); |
| client.on('connect', () => { |
| try { |
| req = client.request({ |
| ':method': 'GET', |
| ':path': '/exa.language_server_pb.LanguageServerService/GetUserStatus', |
| 'x-codeium-csrf-token': DEFAULT_CSRF, |
| }); |
| req.on('response', (headers) => { |
| const contentType = String(headers['content-type'] || '').toLowerCase(); |
| const server = String(headers.server || '').toLowerCase(); |
| const hasGrpcStatus = headers['grpc-status'] != null || headers['grpc-message'] != null; |
| const looksLikeLs = hasGrpcStatus |
| || contentType.includes('grpc') |
| || contentType.includes('connect') |
| || /grpc|connect/.test(server); |
| finish(looksLikeLs); |
| }); |
| req.on('error', () => finish(false)); |
| req.on('end', () => finish(false)); |
| req.end(); |
| } catch { |
| finish(false); |
| } |
| }); |
| }); |
| } |
|
|
| async function waitPortReady(port, timeoutMs = 20000) { |
| const start = Date.now(); |
| while (Date.now() - start < timeoutMs) { |
| try { |
| await new Promise((resolve, reject) => { |
| const client = http2.connect(`http://localhost:${port}`); |
| const timer = setTimeout(() => { try { client.close(); } catch {} reject(new Error('timeout')); }, 2000); |
| client.on('connect', () => { clearTimeout(timer); client.close(); resolve(); }); |
| client.on('error', (e) => { clearTimeout(timer); try { client.close(); } catch {} reject(e); }); |
| }); |
| return true; |
| } catch { |
| await new Promise(r => setTimeout(r, 500)); |
| } |
| } |
| throw new Error(`LS port ${port} not ready after ${timeoutMs}ms`); |
| } |
|
|
| |
| |
| |
| |
| export async function ensureLs(proxy = null) { |
| const key = proxyKey(proxy); |
| const existing = _pool.get(key); |
| if (existing && existing.ready) return existing; |
|
|
| |
| |
| |
| |
| const pending = _pending.get(key); |
| if (pending) return pending; |
|
|
| |
| if (key !== 'default' && _pool.size >= MAX_LS_INSTANCES) { |
| let lruKey = null; |
| let lruTime = Infinity; |
| for (const [k, e] of _pool) { |
| if (k === 'default') continue; |
| const at = e._evictAt || e.startedAt || 0; |
| if (at < lruTime) { lruTime = at; lruKey = k; } |
| } |
| if (lruKey) { |
| const evicted = _pool.get(lruKey); |
| _intentionalShutdown.add(lruKey); |
| try { evicted?.process?.kill('SIGTERM'); } catch {} |
| _pool.delete(lruKey); |
| log.warn(`LS pool at cap (${MAX_LS_INSTANCES}), evicted LRU instance ${lruKey} (started ${evicted?.startedAt ? new Date(evicted.startedAt).toISOString() : '?'})`); |
| } |
| } |
|
|
| const promise = (async () => { |
| const isDefault = key === 'default'; |
| let port = isDefault ? DEFAULT_PORT : _nextPort++; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if (isDefault && await isPortInUse(port)) { |
| log.warn(`LS default port ${port} already in use; starting LS on next free port instead of adopting (security)`); |
| do { |
| port = _nextPort++; |
| } while (await isPortInUse(port)); |
| } |
|
|
| |
| |
| |
| |
| |
| if (!isDefault) { |
| let tries = 0; |
| while (await isPortInUse(port)) { |
| if (++tries > 50) throw new Error(`No free port for LS in range starting ${DEFAULT_PORT + 1}`); |
| log.debug(`LS port ${port} busy, advancing`); |
| port = _nextPort++; |
| } |
| } |
|
|
| const dataDir = dataDirForKey(key); |
| try { mkdirSync(`${dataDir}/db`, { recursive: true }); } catch (e) { log.warn(`mkdirSync ${dataDir}/db: ${e.message}`); } |
|
|
| const args = [ |
| `--api_server_url=${_apiServerUrl}`, |
| `--server_port=${port}`, |
| `--csrf_token=${DEFAULT_CSRF}`, |
| `--register_user_url=https://api.codeium.com/register_user/`, |
| `--codeium_dir=${dataDir}`, |
| `--database_dir=${dataDir}/db`, |
| '--detect_proxy=false', |
| ]; |
|
|
| const pUrl = proxyUrl(proxy); |
| const env = buildLanguageServerEnv(process.env, { proxyUrl: pUrl }); |
|
|
| |
| |
| if (!existsSync(_binaryPath)) { |
| log.error( |
| `Language server binary not found at ${_binaryPath}. ` + |
| `Install it with: bash install-ls.sh (or set LS_BINARY_PATH env var)` |
| ); |
| } |
|
|
| log.info(`Starting LS instance key=${key} port=${port} proxy=${redactProxyUrl(pUrl)}`); |
|
|
| const proc = spawn(_binaryPath, args, { |
| stdio: ['pipe', 'pipe', 'pipe'], |
| env, |
| }); |
|
|
| proc.stdout.on('data', (data) => { |
| const lines = data.toString().trim().split('\n'); |
| for (const line of lines) { |
| if (!line) continue; |
| if (/ERROR|error/.test(line)) log.error(`[LS:${key}] ${line}`); |
| else log.debug(`[LS:${key}] ${line}`); |
| } |
| }); |
| proc.stderr.on('data', (data) => { |
| const line = data.toString().trim(); |
| if (line) log.warn(`[LS:${key}:err] ${line}`); |
| }); |
| proc.on('exit', (code, signal) => { |
| log.warn(`LS instance ${key} exited: code=${code} signal=${signal}`); |
| if (code === 1) { |
| log.error('LS crashed on startup. Common causes:'); |
| log.error(' 1. Binary incompatible with this OS/arch — re-download with: bash install-ls.sh'); |
| log.error(' 2. Missing glibc/libstdc++ — run: ldd ' + _binaryPath + ' | grep "not found"'); |
| log.error(' 3. Binary corrupted — delete and re-download: rm ' + _binaryPath + ' && bash install-ls.sh'); |
| log.error(' 4. Port already in use — check: lsof -i :' + port); |
| } |
| const gone = _pool.get(key); |
| const goneGen = gone?.generation; |
| const gonePort = gone?.port; |
| _pool.delete(key); |
| if (gonePort) { |
| closeSessionForPort(gonePort); |
| import('./conversation-pool.js').then(m => m.invalidateFor({ lsPort: gonePort, lsGeneration: goneGen })).catch(() => {}); |
| } |
|
|
| |
| |
| |
| |
| |
| if (AUTO_RESTART_ENABLED && gone && !_intentionalShutdown.has(key)) { |
| scheduleLsRestart(key, gone.proxy, gonePort); |
| } |
| _intentionalShutdown.delete(key); |
| }); |
| proc.on('error', (err) => { |
| if (err.code === 'ENOEXEC') { |
| const os = process.platform; |
| log.error( |
| `LS binary is not executable on this platform (${os}). ` + |
| `The binary at ${_binaryPath} is likely built for a different OS/arch. ` + |
| (os === 'darwin' |
| ? 'You need the macOS build: copy language_server_macos_arm (Apple Silicon) or language_server_macos_x64 (Intel) from your Windsurf desktop app.' |
| : os === 'win32' |
| ? 'LS binary only runs on Linux. Use WSL2 or a Linux VM.' |
| : `Ensure the binary matches your arch: ${process.arch}`) |
| ); |
| } else { |
| log.error(`LS instance ${key} spawn error: ${err.message}`); |
| } |
| _pool.delete(key); |
| }); |
|
|
| const entry = { |
| process: proc, port, csrfToken: DEFAULT_CSRF, |
| proxy, startedAt: Date.now(), ready: false, |
| |
| |
| |
| generation: randomUUID(), |
| |
| |
| |
| workspaceInit: null, |
| sessionId: null, |
| }; |
| _pool.set(key, entry); |
|
|
| try { |
| await waitPortReady(port, 25000); |
| entry.ready = true; |
| log.info(`LS instance ${key} ready on port ${port}`); |
| } catch (err) { |
| log.error(`LS instance ${key} failed to become ready: ${err.message}`); |
| try { proc.kill('SIGKILL'); } catch {} |
| _pool.delete(key); |
| throw err; |
| } |
| return entry; |
| })(); |
|
|
| _pending.set(key, promise); |
| try { |
| return await promise; |
| } finally { |
| _pending.delete(key); |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function restartLsForProxy(proxy) { |
| const key = proxyKey(proxy); |
| const entry = _pool.get(key); |
| _intentionalShutdown.add(key); |
| if (entry?.process) { |
| try { entry.process.kill('SIGTERM'); } catch {} |
| } |
| if (entry?.port) { |
| |
| |
| |
| |
| closeSessionForPort(entry.port); |
| try { |
| const m = await import('./conversation-pool.js'); |
| m.invalidateFor({ lsPort: entry.port, lsGeneration: entry.generation }); |
| } catch {} |
| } |
| _pool.delete(key); |
| return ensureLs(proxy); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function getLsFor(proxy) { |
| const entry = _pool.get(proxyKey(proxy)); |
| if (entry) entry._evictAt = Date.now(); |
| return entry || null; |
| } |
|
|
| |
|
|
| const _restartAttempts = new Map(); |
|
|
| function scheduleLsRestart(key, proxy, oldPort) { |
| const attempts = (_restartAttempts.get(key) || 0) + 1; |
| if (attempts > AUTO_RESTART_MAX_RETRIES) { |
| log.error(`LS auto-restart: ${key} exceeded max retries (${AUTO_RESTART_MAX_RETRIES}), giving up`); |
| _restartAttempts.delete(key); |
| return; |
| } |
|
|
| const delay = AUTO_RESTART_BASE_DELAY_MS * Math.pow(2, attempts - 1); |
| _restartAttempts.set(key, attempts); |
|
|
| log.info(`LS auto-restart: scheduling ${key} restart #${attempts} in ${delay}ms`); |
|
|
| setTimeout(async () => { |
| try { |
| await ensureLs(proxy); |
| _restartAttempts.delete(key); |
| log.info(`LS auto-restart: ${key} restarted successfully (attempt #${attempts})`); |
| } catch (err) { |
| log.error(`LS auto-restart: ${key} restart #${attempts} failed: ${err.message}`); |
| if (attempts < AUTO_RESTART_MAX_RETRIES) { |
| scheduleLsRestart(key, proxy, oldPort); |
| } |
| } |
| }, delay).unref(); |
| } |
|
|
| export function getRestartStats() { |
| const stats = {}; |
| for (const [key, attempts] of _restartAttempts) { |
| stats[key] = attempts; |
| } |
| return stats; |
| } |
|
|
| |
| |
| |
| |
| |
| export function getLsEntryByPort(port) { |
| for (const entry of _pool.values()) { |
| if (entry.port === port) return entry; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| export function _poolKeys() { |
| return [..._pool.keys()]; |
| } |
|
|
| |
| |
| |
| |
| export function getProxyByKey(key) { |
| const entry = _pool.get(key); |
| return entry?.proxy || null; |
| } |
|
|
| |
|
|
| export function getLsPort() { |
| return _pool.get('default')?.port || DEFAULT_PORT; |
| } |
| export function getCsrfToken() { |
| return _pool.get('default')?.csrfToken || DEFAULT_CSRF; |
| } |
|
|
| |
| |
| |
| export async function startLanguageServer(opts = {}) { |
| _binaryPath = opts.binaryPath || process.env.LS_BINARY_PATH || _binaryPath; |
| _apiServerUrl = opts.apiServerUrl || process.env.CODEIUM_API_URL || _apiServerUrl; |
| const def = await ensureLs(null); |
| return { port: def.port, csrfToken: def.csrfToken }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function cleanupOrphanLanguageServers() { |
| if (process.platform === 'win32') return { scanned: 0, killed: 0 }; |
| let scanned = 0; |
| let killed = 0; |
| const ourPids = new Set(); |
| for (const entry of _pool.values()) { |
| if (entry?.process?.pid) ourPids.add(entry.process.pid); |
| } |
| const targetBinaries = new Set([_binaryPath, DEFAULT_BINARY]); |
| try { |
| |
| |
| |
| const out = execSync('ps -e -o pid=,args=', { timeout: 3000, encoding: 'utf-8' }); |
| for (const line of out.split('\n')) { |
| const trimmed = line.trim(); |
| if (!trimmed) continue; |
| const m = trimmed.match(/^(\d+)\s+(.*)$/); |
| if (!m) continue; |
| const pid = parseInt(m[1], 10); |
| const argv = m[2]; |
| |
| |
| |
| |
| const argv0 = argv.split(/\s+/, 1)[0]; |
| let isOurs = false; |
| for (const bin of targetBinaries) { |
| if (bin && argv0 === bin) { isOurs = true; break; } |
| } |
| if (!isOurs) continue; |
| scanned++; |
| if (ourPids.has(pid)) continue; |
| if (pid === process.pid) continue; |
| try { |
| process.kill(pid, 'SIGTERM'); |
| killed++; |
| log.info(`Killed orphan LS pid=${pid} (${argv.slice(0, 80)}...)`); |
| } catch (e) { |
| if (e.code !== 'ESRCH') log.warn(`Could not kill orphan LS pid=${pid}: ${e.message}`); |
| } |
| } |
| } catch (e) { |
| log.warn(`cleanupOrphanLanguageServers: ${e.message}`); |
| } |
| return { scanned, killed }; |
| } |
|
|
| export function stopLanguageServer() { |
| |
| |
| |
| const portsToClose = []; |
| for (const [key, entry] of _pool) { |
| _intentionalShutdown.add(key); |
| try { entry.process?.kill('SIGTERM'); } catch {} |
| if (entry?.port) portsToClose.push({ port: entry.port, generation: entry.generation }); |
| log.info(`LS instance ${key} stopped`); |
| } |
| _pool.clear(); |
| if (portsToClose.length) { |
| import('./conversation-pool.js').then(m => { |
| for (const p of portsToClose) { |
| closeSessionForPort(p.port); |
| m.invalidateFor({ lsPort: p.port, lsGeneration: p.generation }); |
| } |
| }).catch(() => {}); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function stopLanguageServerAndWait({ perProcessTimeoutMs = 1500 } = {}) { |
| const procs = []; |
| const portsToClose = []; |
| for (const [key, entry] of _pool) { |
| if (entry?.process) procs.push({ key, proc: entry.process }); |
| if (entry?.port) portsToClose.push({ port: entry.port, generation: entry.generation }); |
| } |
| _pool.clear(); |
| await Promise.allSettled(procs.map(({ key, proc }) => new Promise((resolve) => { |
| let settled = false; |
| const finish = (how) => { |
| if (settled) return; |
| settled = true; |
| log.info(`LS instance ${key} stopped (${how})`); |
| resolve(); |
| }; |
| try { proc.once('exit', () => finish('exited')); } catch {} |
| try { proc.kill('SIGTERM'); } catch (e) { finish(`kill failed: ${e.message}`); return; } |
| setTimeout(() => { |
| if (settled) return; |
| try { proc.kill('SIGKILL'); } catch {} |
| finish(`SIGKILL after ${perProcessTimeoutMs}ms`); |
| }, perProcessTimeoutMs).unref(); |
| }))); |
| if (portsToClose.length) { |
| try { |
| const m = await import('./conversation-pool.js'); |
| for (const p of portsToClose) { |
| closeSessionForPort(p.port); |
| m.invalidateFor({ lsPort: p.port, lsGeneration: p.generation }); |
| } |
| } catch {} |
| } |
| } |
|
|
| export function isLanguageServerRunning() { |
| return _pool.size > 0; |
| } |
|
|
| export async function waitForReady() { |
| const def = _pool.get('default'); |
| if (!def) throw new Error('default LS not initialized'); |
| if (def.ready) return true; |
| await waitPortReady(def.port, 20000); |
| def.ready = true; |
| return true; |
| } |
|
|
| export function getLsStatus() { |
| const def = _pool.get('default'); |
| return { |
| running: _pool.size > 0, |
| pid: def?.process?.pid || null, |
| port: def?.port || DEFAULT_PORT, |
| startedAt: def?.startedAt || null, |
| restartCount: 0, |
| instances: Array.from(_pool.entries()).map(([key, e]) => ({ |
| key, port: e.port, |
| pid: e.process?.pid || null, |
| proxy: e.proxy ? `${e.proxy.host}:${e.proxy.port}` : null, |
| startedAt: e.startedAt, |
| ready: e.ready, |
| })), |
| }; |
| } |
|
|