Ctrl K
ComunidadGuías…

src/langserver.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Language server pool manager.
 * Spawns multiple LS instances — one per unique outbound proxy (plus a default
 * no-proxy instance). Accounts are routed to the LS instance matching their
 * configured proxy so that each upstream Codeium request goes out through the
 * right egress IP. Also avoids the LS state-pollution bug where switching
 * accounts within a single LS session causes workspace setup streams to be
 * canceled.
 */

import { spawn, execSync } from 'child_process';
import { mkdirSync, readFileSync } from 'fs';
import { existsSync } from 'fs';
import http2 from 'http2';
import net from 'net';
import { randomUUID } from 'crypto';
import { freemem, totalmem } from 'os';
import { posix, 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';

// v2.0.96: cap LS pool size to prevent memory blowup (#174).
// v2.0.99+: make the default adaptive. Current LS builds can consume
// ~500-600MB RSS including the child worker, so a fixed default of 20 is
// unsafe on 2GB VPSes. Operators with large machines can still override.
const DEFAULT_LS_RSS_ESTIMATE_BYTES = 700 * 1024 * 1024;
const RSS_SNAPSHOT_TTL_MS = 5000;

function positiveIntEnv(name, fallback, min = 0) {
  const n = parseInt(process.env[name] || '', 10);
  return Number.isFinite(n) && n >= min ? n : fallback;
}

function bytesEnv(name, fallback) {
  const raw = String(process.env[name] || '').trim();
  if (!raw) return fallback;
  const m = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|k|mb|mib|m|gb|gib|g)?$/i);
  if (!m) return fallback;
  const n = Number(m[1]);
  if (!Number.isFinite(n) || n < 0) return fallback;
  const unit = (m[2] || 'b').toLowerCase();
  const mul = unit === 'gb' || unit === 'gib' || unit === 'g' ? 1024 ** 3
    : unit === 'mb' || unit === 'mib' || unit === 'm' ? 1024 ** 2
      : unit === 'kb' || unit === 'kib' || unit === 'k' ? 1024
        : 1;
  return Math.floor(n * mul);
}

export function detectMemoryLimitBytes(readFile = readFileSync, hostTotalBytes = totalmem()) {
  const candidates = [];
  const readLimit = (path) => {
    try {
      const raw = String(readFile(path, 'utf-8')).trim();
      if (!raw || raw === 'max') return;
      const n = Number(raw);
      if (Number.isFinite(n) && n > 0) candidates.push(n);
    } catch {}
  };
  readLimit('/sys/fs/cgroup/memory.max');
  readLimit('/sys/fs/cgroup/memory/memory.limit_in_bytes');
  const hostTotal = Number(hostTotalBytes) || 0;
  const sane = candidates
    // cgroup v1 often reports absurd sentinel values when unlimited.
    .filter(n => n < 1_000_000_000_000_000)
    .filter(n => hostTotal <= 0 || n <= hostTotal);
  return sane.length ? Math.min(...sane) : hostTotal;
}

export function detectMemoryCurrentBytes(readFile = readFileSync) {
  for (const path of ['/sys/fs/cgroup/memory.current', '/sys/fs/cgroup/memory/memory.usage_in_bytes']) {
    try {
      const raw = String(readFile(path, 'utf-8')).trim();
      const n = Number(raw);
      if (Number.isFinite(n) && n >= 0) return n;
    } catch {}
  }
  return null;
}

export function detectHostMemAvailableBytes(readFile = readFileSync, fallback = freemem()) {
  try {
    const raw = String(readFile('/proc/meminfo', 'utf-8'));
    const m = raw.match(/^MemAvailable:\s+(\d+)\s+kB/im);
    if (m) return parseInt(m[1], 10) * 1024;
  } catch {}
  const n = Number(fallback);
  return Number.isFinite(n) && n >= 0 ? n : null;
}

export function estimateDefaultMaxLsInstances(totalBytes = totalmem(), perInstanceBytes = DEFAULT_LS_RSS_ESTIMATE_BYTES) {
  const total = Number(totalBytes) || 0;
  const per = Number(perInstanceBytes) || DEFAULT_LS_RSS_ESTIMATE_BYTES;
  if (total <= 0 || per <= 0) return 2;
  // Keep one slot for the default LS plus one proxy slot. The default LS is
  // warmed on startup and is intentionally not LRU-evicted, so returning 1 on
  // tiny cgroups would make every proxied account fail with LS_POOL_EXHAUSTED.
  return Math.max(2, Math.min(20, Math.floor(total / per)));
}
const MAX_LS_INSTANCES = (() => {
  const n = parseInt(process.env.LS_MAX_INSTANCES || '', 10);
  return Number.isFinite(n) && n > 0 ? n : estimateDefaultMaxLsInstances(detectMemoryLimitBytes());
})();
const LS_POOL_WAIT_MS = positiveIntEnv('LS_POOL_WAIT_MS', 30_000, 0);
const LS_MEMORY_GUARD_ENABLED = process.env.LS_MEMORY_GUARD !== '0';
const LS_SPAWN_MIN_AVAILABLE_BYTES_RAW = String(process.env.LS_SPAWN_MIN_AVAILABLE_BYTES || '').trim();
const LS_SPAWN_MIN_AVAILABLE_BYTES_EXPLICIT = !!LS_SPAWN_MIN_AVAILABLE_BYTES_RAW;
const LS_SPAWN_MIN_AVAILABLE_BYTES = bytesEnv('LS_SPAWN_MIN_AVAILABLE_BYTES', DEFAULT_LS_RSS_ESTIMATE_BYTES);
const LS_OBSERVED_RSS_MIN_BYTES = bytesEnv('LS_OBSERVED_RSS_MIN_BYTES', 384 * 1024 * 1024);
const LS_OBSERVED_RSS_MARGIN_PERCENT = positiveIntEnv('LS_OBSERVED_RSS_MARGIN_PERCENT', 35, 0);

const LS_IDLE_TTL_MS = (() => {
  const n = parseInt(process.env.LS_IDLE_TTL_MS || '', 10);
  return Number.isFinite(n) && n >= 0 ? n : 20 * 60 * 1000;
})();
const LS_IDLE_SWEEP_MS = (() => {
  const n = parseInt(process.env.LS_IDLE_SWEEP_MS || '', 10);
  if (Number.isFinite(n) && n > 0) return n;
  return LS_IDLE_TTL_MS > 0 ? Math.max(60_000, Math.min(5 * 60_000, Math.floor(LS_IDLE_TTL_MS / 2))) : 0;
})();

// Auto-restart configuration (env-overridable)
const AUTO_RESTART_ENABLED = process.env.LS_AUTO_RESTART !== '0'; // default: on
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;
})();

// Pool: key -> { process, port, csrfToken, proxy, startedAt, ready }
const _pool = new Map();
const _maintenanceUses = new Map();
// In-flight Promise map so two concurrent ensureLs(proxy) calls for the
// same key share one spawn + readiness wait. Without this, both callers
// would each spawn an LS process, race on the port, and leave an orphan.
const _pending = new Map();
// key -> monotonically increasing admission sequence. These reservations are
// counted by capacity/memory guards before the LS placeholder reaches _pool.
const _pendingStartSeq = new Map();
// Evicted processes are kept as reservations until the OS confirms exit.
// This prevents transient RSS spikes where old and new LS workers overlap.
const _stopping = new Map();
let _startAdmissionQueue = Promise.resolve();
// Track concrete LS processes being shut down intentionally so the exit
// handler doesn't fire an auto-restart for them. This must be per-process,
// not per-key: restartLsForProxy() can spawn a new same-key LS before the
// old process emits exit.
const _intentionalShutdownProcs = new WeakSet();
let _nextPort = DEFAULT_PORT + 1;
let _binaryPath = DEFAULT_BINARY;
let _apiServerUrl = DEFAULT_API_URL;
let _idleSweepTimer = null;
let _rssSnapshot = { at: 0, pidKey: '', byRootPid: new Map() };
let _nextPendingStartSeq = 1;
const _admissionStats = {
  startAttempts: 0,
  startSuccesses: 0,
  startFailures: 0,
  poolWaits: 0,
  memoryWaits: 0,
  poolExhausted: 0,
  memoryGuardBlocks: 0,
  evictions: 0,
  lastAttempt: null,
  lastSuccess: null,
  lastFailure: null,
  lastWait: null,
  lastEviction: null,
};

function lsPoolExhaustedError(message) {
  const err = new Error(message);
  err.code = 'LS_POOL_EXHAUSTED';
  err.status = 503;
  err.type = 'ls_pool_exhausted';
  err.isResourceExhausted = true;
  return err;
}

// v2.0.71 (#119 follow-up): heuristic to recognise sticky-IP proxy
// usernames. Common providers embed a session/IP token inside the
// username so the same host:port serves many distinct egress IPs:
//   ipwo:        username_sid_xxxxxxx
//   lunaproxy:   user-zone-residential-session-xxx
//   smartproxy:  spxxxxx-session-xxx-stickyXX
//   bright data: brd-customer-xxx-zone-xxx[-session-xxx]
//   oxylabs:     customer-xxxxx-cc-xxx[-sessid-xxx]
// Match any of these and segregate LS automatically. Falls back to
// host:port when the username doesn't fit the pattern (avoids
// LS-per-account memory blow up for static-IP proxies that intentionally
// share an egress).
//
// v2.0.79 (audit M-1) — original regex only caught usernames that
// contained an explicit session token (`_sid_`, `-session-`, `+ws_`).
// Bright Data and Oxylabs username schemas don't always include the
// session token (some plans use a stable username + rotating egress
// per-request, but the upstream still treats the username itself as
// the routing fingerprint). Those usernames look like:
//   brd-customer-hl_abc123-zone-residential
//   customer-myuser-cc-US-country-US
// They'd hash to the same proxyKey as a static-IP shared username
// (because there's no `session` token), so the LS pool would lump
// every account behind that proxy onto one shared LS instance, which
// is what wnfilm and 0a00 reported in #118 — "30 minute rate-limit
// even though I have 31 trial accounts behind it". Widen the regex
// to also catch `brd-customer-` prefix and `customer-...-cc-` /
// `customer-...-zone-` patterns. Static-IP proxies whose username is
// a bare login (no provider-specific markers) still skip
// segregation, so memory stays bounded.
// v2.0.93: expanded sticky detection. Any proxy username with a provider-
// specific token, session marker, or non-trivial structure gets its own LS.
// This catches more providers (iproyal, webshare, proxy-seller, etc) and
// reduces cross-account LS sharing which triggers upstream rate limits.
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';
  // Sanitize to [A-Za-z0-9_] — the key flows into a filesystem path
  // (`${LS_DATA_DIR}/${key}`) and a shell-quoted mkdir, so strip any
  // special character that could slip past execSync's naive quoting.
  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}`;
  // v2.0.68/v2.0.71 (#119 CharwinYAO): sticky-IP proxy services (ipwo,
  // lunaproxy, smartproxy, oxylabs, bright data) embed a per-IP session
  // id inside the username. Default behaviour pools all sticky sessions
  // onto one LS instance (host:port identical) so multiple accounts
  // share one LS sessionId / Windsurf fingerprint, tripping upstream's
  // 30-min rate limit even though egress IPs differ.
  //
  // Auto-on (v2.0.71): username matches a known sticky session pattern
  // (`_sid_`, `-session-`, `-sticky`, `-sessid-`, `+ws_`) → segregate.
  // Static-IP proxies don't carry these markers so memory stays bounded.
  // Operator can force-on (`WINDSURFAPI_LS_PER_PROXY_USER=1`) to
  // segregate every distinct username, or force-off (`=0`) to disable.
  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) {
    // Cap user portion at 32 chars to keep filesystem paths sane on
    // Windows where MAX_PATH still bites; sticky session ids are
    // typically <16 chars anyway.
    const safeUser = String(proxy.username).replace(/[^a-zA-Z0-9]/g, '_').slice(0, 32);
    if (safeUser) key += `_u${safeUser}`;
  }
  return key;
}

function publicLsKey(key) {
  const s = String(key || '');
  const m = s.match(/^(px_.+_[0-9]+)_u[A-Za-z0-9_]{1,32}$/);
  return m ? `${m[1]}_u_redacted` : s;
}

export function defaultLsDataRoot(platform = process.platform, home = process.env.HOME) {
  return platform === 'darwin'
    ? posix.join(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}`;
}

function touchEntry(entry) {
  if (!entry) return;
  const now = Date.now();
  entry.lastUsedAt = now;
  entry._evictAt = now;
}

function markIntentionalShutdown(entryOrProc) {
  const proc = entryOrProc?.process || entryOrProc;
  if (proc && typeof proc === 'object') _intentionalShutdownProcs.add(proc);
}

function isIntentionalShutdown(proc) {
  return !!proc && _intentionalShutdownProcs.has(proc);
}

function clearIntentionalShutdown(proc) {
  try { if (proc) _intentionalShutdownProcs.delete(proc); } catch {}
}

function keyByPort(port) {
  for (const [key, entry] of _pool) {
    if (entry?.port === port) return key;
  }
  return '';
}

export function beginLsUse(port) {
  const entry = getLsEntryByPort(port);
  if (!entry) return null;
  const key = keyByPort(port);
  if (key && (_maintenanceUses.get(key) || 0) > 0) return null;
  entry.activeRequests = (entry.activeRequests || 0) + 1;
  touchEntry(entry);
  return entry;
}

export function endLsUse(port) {
  const entry = getLsEntryByPort(port);
  if (!entry) return;
  entry.activeRequests = Math.max(0, (entry.activeRequests || 0) - 1);
  touchEntry(entry);
}

export function beginLsMaintenanceUse(port) {
  const entry = getLsEntryByPort(port);
  if (!entry) return null;
  if ((entry.activeRequests || 0) > 0) return null;
  const key = keyByPort(port);
  if (!key) return null;
  if ((_maintenanceUses.get(key) || 0) > 0) return null;
  _maintenanceUses.set(key, (_maintenanceUses.get(key) || 0) + 1);
  touchEntry(entry);
  return { key, port, generation: entry.generation };
}

export function endLsMaintenanceUse(token) {
  const key = token?.key || '';
  if (!key) return;
  const n = (_maintenanceUses.get(key) || 0) - 1;
  if (n > 0) _maintenanceUses.set(key, n);
  else _maintenanceUses.delete(key);
  const entry = _pool.get(key);
  if (entry) touchEntry(entry);
}

export function getLsMaintenanceRequests(proxy = null) {
  return _maintenanceUses.get(proxyKey(proxy)) || 0;
}

function invalidateEntryForShutdown(entry) {
  if (!entry?.port) return;
  closeSessionForPort(entry.port);
  import('./conversation-pool.js')
    .then(m => m.invalidateFor({ lsPort: entry.port, lsGeneration: entry.generation }))
    .catch(() => {});
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function admissionEvent(fields = {}) {
  const at = Date.now();
  return { at, atIso: new Date(at).toISOString(), ...fields };
}

function safeAdmissionMessage(err) {
  const s = String(err?.message || err || '').replace(/\s+/g, ' ').trim();
  return s.length > 320 ? `${s.slice(0, 317)}...` : s;
}

function cloneAdmissionEvent(event) {
  if (!event) return null;
  const copy = { ...event };
  if (copy.memoryGuard) copy.memoryGuard = { ...copy.memoryGuard };
  return copy;
}

function admissionStatsSnapshot() {
  return {
    startAttempts: _admissionStats.startAttempts,
    startSuccesses: _admissionStats.startSuccesses,
    startFailures: _admissionStats.startFailures,
    poolWaits: _admissionStats.poolWaits,
    memoryWaits: _admissionStats.memoryWaits,
    poolExhausted: _admissionStats.poolExhausted,
    memoryGuardBlocks: _admissionStats.memoryGuardBlocks,
    evictions: _admissionStats.evictions,
    lastAttempt: cloneAdmissionEvent(_admissionStats.lastAttempt),
    lastSuccess: cloneAdmissionEvent(_admissionStats.lastSuccess),
    lastFailure: cloneAdmissionEvent(_admissionStats.lastFailure),
    lastWait: cloneAdmissionEvent(_admissionStats.lastWait),
    lastEviction: cloneAdmissionEvent(_admissionStats.lastEviction),
  };
}

function recordAdmissionWait(kind, key, extra = {}) {
  if (kind === 'pool_capacity') _admissionStats.poolWaits++;
  if (kind === 'memory_guard') _admissionStats.memoryWaits++;
  _admissionStats.lastWait = admissionEvent({ kind, key: publicLsKey(key), ...extra });
}

function recordAdmissionFailure(kind, key, err, extra = {}) {
  if (kind === 'pool_capacity') _admissionStats.poolExhausted++;
  if (kind === 'memory_guard') _admissionStats.memoryGuardBlocks++;
  const errorType = err?.type || (err?.code === 'LS_MEMORY_GUARD' ? 'ls_memory_guard' : null) || (err?.code === 'LS_POOL_EXHAUSTED' ? 'ls_pool_exhausted' : null) || 'ls_start_failed';
  _admissionStats.lastFailure = admissionEvent({
    kind,
    key: publicLsKey(key),
    errorType,
    code: err?.code || null,
    message: safeAdmissionMessage(err),
    ...extra,
  });
}

function recordStartAttempt(key, extra = {}) {
  _admissionStats.startAttempts++;
  _admissionStats.lastAttempt = admissionEvent({ key: publicLsKey(key), ...extra });
}

function recordStartSuccess(key, extra = {}) {
  _admissionStats.startSuccesses++;
  _admissionStats.lastSuccess = admissionEvent({ key: publicLsKey(key), ...extra });
}

function recordStartFailure(key, err, extra = {}) {
  _admissionStats.startFailures++;
  recordAdmissionFailure('start_failed', key, err, extra);
}

async function waitPortReadyOrProcessExit(proc, port, timeoutMs) {
  let cleanup = () => {};
  const procFailure = new Promise((resolve, reject) => {
    try {
      const onError = (err) => reject(err);
      const onExit = (code, signal) => {
        reject(new Error(`LS process exited before ready: code=${code} signal=${signal}`));
      };
      proc.once('error', onError);
      proc.once('exit', onExit);
      cleanup = () => {
        try { proc.off?.('error', onError); } catch {}
        try { proc.off?.('exit', onExit); } catch {}
      };
    } catch (e) {
      reject(e);
    }
  });
  try {
    return await Promise.race([waitPortReady(port, timeoutMs), procFailure]);
  } finally {
    cleanup();
  }
}

function withStartAdmissionLock(fn) {
  const prev = _startAdmissionQueue;
  let release;
  _startAdmissionQueue = new Promise(resolve => { release = resolve; });
  return (async () => {
    try { await prev; } catch {}
    try { return await fn(); }
    finally { release(); }
  })();
}

function poolOccupancy() {
  return _pool.size + _stopping.size;
}

function pendingStartReservationCount({ excludeKey = '', beforeSeq = Infinity } = {}) {
  let count = 0;
  for (const [key, seq] of _pendingStartSeq) {
    if (key === excludeKey) continue;
    if (_pool.has(key)) continue;
    if (seq < beforeSeq) count++;
  }
  return count;
}

function poolOccupancyWithPendingReservations(opts = {}) {
  return poolOccupancy() + pendingStartReservationCount(opts);
}

function activeSpawnReservationCount(opts = {}) {
  const { excludeKey = '', beforeSeq = Infinity } = typeof opts === 'string'
    ? { excludeKey: opts, beforeSeq: Infinity }
    : opts;
  let count = 0;
  for (const [key, entry] of _pool) {
    if (key === excludeKey) continue;
    if (entry && !entry.ready) count++;
  }
  count += pendingStartReservationCount({ excludeKey, beforeSeq });
  return count;
}

export function hasLsPoolCapacityForStart(effectiveOccupancy, maxInstances = MAX_LS_INSTANCES, evictableSlots = 0) {
  const effective = Math.max(0, Number(effectiveOccupancy) || 0);
  const max = Math.max(1, Number(maxInstances) || 1);
  const evictable = Math.max(0, Number(evictableSlots) || 0);
  return effective < max || effective - evictable < max;
}

const LS_EVICT_READY_GRACE_MS = positiveIntEnv('LS_EVICT_READY_GRACE_MS', 5000, 0);

function getIdleNonDefaultEvictionCandidates() {
  const candidates = [];
  const now = Date.now();
  for (const [k, e] of _pool) {
    if (k === 'default') continue;
    if (!e?.ready) continue;
    if ((e.activeRequests || 0) > 0) continue;
    if ((_maintenanceUses.get(k) || 0) > 0) continue;
    if (e.readyAt && now - e.readyAt < LS_EVICT_READY_GRACE_MS) continue;
    const at = e.lastUsedAt || e._evictAt || e.startedAt || 0;
    candidates.push({
      key: k,
      entry: e,
      at,
      idleMs: e?.lastUsedAt ? Math.max(0, now - e.lastUsedAt) : null,
    });
  }
  candidates.sort((a, b) => a.at - b.at);
  return candidates;
}

function findIdleNonDefaultEvictionCandidate() {
  return getIdleNonDefaultEvictionCandidates()[0] || null;
}

function countIdleNonDefaultEvictionCandidates() {
  return getIdleNonDefaultEvictionCandidates().length;
}

async function waitProcessExit(proc, timeoutMs) {
  if (!proc || proc.exitCode != null || proc.signalCode != null) return 'already_exited';
  return new Promise(resolve => {
    let settled = false;
    const done = (how) => {
      if (settled) return;
      settled = true;
      try { proc.off?.('exit', onExit); } catch {}
      clearTimeout(timer);
      resolve(how);
    };
    const onExit = () => done('exited');
    try { proc.once('exit', onExit); } catch { return done('no_listener'); }
    const timer = setTimeout(() => done('timeout'), timeoutMs);
    try { timer.unref?.(); } catch {}
  });
}

async function evictLruIdleNonDefault() {
  const candidate = findIdleNonDefaultEvictionCandidate();
  if (!candidate) return null;
  const { key: lruKey, entry: evicted } = candidate;
  _stopping.set(lruKey, { at: Date.now(), pid: evicted?.process?.pid || null, reason: 'evicted' });
  _pool.delete(lruKey);
  _maintenanceUses.delete(lruKey);
  markIntentionalShutdown(evicted);
  invalidateEntryForShutdown(evicted);
  try { evicted?.process?.kill('SIGTERM'); } catch {}
  let how = await waitProcessExit(evicted?.process, 1500);
  if (how === 'timeout') {
    try { evicted?.process?.kill('SIGKILL'); } catch {}
    how = await waitProcessExit(evicted?.process, 500);
  }
  _stopping.delete(lruKey);
  _admissionStats.evictions++;
  _admissionStats.lastEviction = admissionEvent({
    key: publicLsKey(lruKey),
    pid: evicted?.process?.pid || null,
    exit: how,
    startedAt: evicted?.startedAt || null,
  });
  log.warn(`LS pool at cap (${MAX_LS_INSTANCES}), evicted LRU instance ${lruKey} (${how}; started ${evicted?.startedAt ? new Date(evicted.startedAt).toISOString() : '?'})`);
  if (!lruKey) return null;
  return lruKey;
}

function hasIdleNonDefaultInstance() {
  return !!findIdleNonDefaultEvictionCandidate();
}

function memoryGuardSnapshot() {
  const limit = detectMemoryLimitBytes();
  const current = detectMemoryCurrentBytes();
  const cgroupAvailableBytes = Number.isFinite(limit) && Number.isFinite(current)
    ? Math.max(0, limit - current)
    : null;
  const hostAvailableBytes = detectHostMemAvailableBytes();
  const candidates = [cgroupAvailableBytes, hostAvailableBytes]
    .filter(n => Number.isFinite(n) && n >= 0);
  return {
    enabled: LS_MEMORY_GUARD_ENABLED,
    configuredMinAvailableBytes: LS_SPAWN_MIN_AVAILABLE_BYTES,
    cgroupAvailableBytes,
    hostAvailableBytes,
    availableBytes: candidates.length ? Math.min(...candidates) : null,
  };
}

function observedLsRssEstimateBytes(now = Date.now()) {
  const rssByRootPid = collectTrackedRssSnapshot(now);
  const samples = [];
  for (const entry of _pool.values()) {
    if (!entry?.ready) continue;
    const pid = entry.process?.pid;
    const rssBytes = pid ? rssByRootPid.get(pid)?.rssBytes : null;
    if (Number.isFinite(rssBytes) && rssBytes > 0) samples.push(rssBytes);
  }
  if (!samples.length) return null;
  const maxObserved = Math.max(...samples);
  const withMargin = Math.ceil(maxObserved * (100 + LS_OBSERVED_RSS_MARGIN_PERCENT) / 100);
  return Math.max(LS_OBSERVED_RSS_MIN_BYTES, withMargin);
}

function effectiveLsSpawnEstimate(now = Date.now()) {
  const observedRssEstimateBytes = observedLsRssEstimateBytes(now);
  if (LS_SPAWN_MIN_AVAILABLE_BYTES_EXPLICIT) {
    return {
      estimateBytes: LS_SPAWN_MIN_AVAILABLE_BYTES,
      minAvailableBytes: LS_SPAWN_MIN_AVAILABLE_BYTES,
      source: 'env',
      observedRssEstimateBytes,
    };
  }
  if (Number.isFinite(observedRssEstimateBytes) && observedRssEstimateBytes > 0) {
    return {
      estimateBytes: observedRssEstimateBytes,
      minAvailableBytes: observedRssEstimateBytes,
      source: 'observed_rss',
      observedRssEstimateBytes,
    };
  }
  return {
    estimateBytes: DEFAULT_LS_RSS_ESTIMATE_BYTES,
    minAvailableBytes: DEFAULT_LS_RSS_ESTIMATE_BYTES,
    source: 'default_estimate',
    observedRssEstimateBytes: null,
  };
}

export function getLsMemoryGuardStatus({ reservedStarts = 0 } = {}) {
  const snap = memoryGuardSnapshot();
  const spawnEstimate = effectiveLsSpawnEstimate();
  const availableBytes = snap.availableBytes == null
    ? null
    : Math.max(0, snap.availableBytes - (Math.max(0, reservedStarts) * spawnEstimate.estimateBytes));
  return {
    ...snap,
    reservedStarts,
    minAvailableBytes: spawnEstimate.minAvailableBytes,
    estimatedRssBytesPerInstance: spawnEstimate.estimateBytes,
    observedRssEstimateBytes: spawnEstimate.observedRssEstimateBytes,
    minAvailableBytesSource: spawnEstimate.source,
    observedRssMinBytes: LS_OBSERVED_RSS_MIN_BYTES,
    observedRssMarginPercent: LS_OBSERVED_RSS_MARGIN_PERCENT,
    availableBytes,
    okToSpawn: !snap.enabled || availableBytes == null || availableBytes >= spawnEstimate.minAvailableBytes,
  };
}

function getLsPoolSummary(now = Date.now()) {
  let ready = 0;
  let starting = 0;
  let activeRequests = 0;
  let nonDefaultInstances = 0;
  let defaultRunning = false;
  for (const [key, entry] of _pool) {
    if (key === 'default') defaultRunning = true;
    else nonDefaultInstances++;
    if (entry?.ready) ready++;
    else starting++;
    activeRequests += entry?.activeRequests || 0;
  }
  let maintenanceRequests = 0;
  for (const n of _maintenanceUses.values()) maintenanceRequests += Math.max(0, n || 0);
  const pendingKeys = Array.from(_pending.keys()).map(publicLsKey);
  const stoppingInstances = Array.from(_stopping.entries()).map(([key, entry]) => ({
    key: publicLsKey(key),
    pid: entry?.pid || null,
    reason: entry?.reason || null,
    at: entry?.at || null,
    ageMs: entry?.at ? Math.max(0, now - entry.at) : null,
  }));
  const evictionCandidate = findIdleNonDefaultEvictionCandidate();
  const evictionCandidateCount = countIdleNonDefaultEvictionCandidates();
  const occupancy = poolOccupancy();
  const reservedPendingStarts = pendingStartReservationCount();
  const effectiveOccupancy = occupancy + reservedPendingStarts;
  const memoryGuard = getLsMemoryGuardStatus({ reservedStarts: activeSpawnReservationCount() });
  const poolHasCapacity = hasLsPoolCapacityForStart(effectiveOccupancy, MAX_LS_INSTANCES, evictionCandidateCount);
  const memoryOk = memoryGuard.okToSpawn || memoryGuard.availableBytes == null || !LS_MEMORY_GUARD_ENABLED;
  const blockReason = !memoryOk
    ? 'memory_guard'
    : !poolHasCapacity
      ? 'pool_full_no_idle'
      : null;
  return {
    size: _pool.size,
    occupancy,
    effectiveOccupancy,
    maxInstances: MAX_LS_INSTANCES,
    ready,
    starting,
    pending: _pending.size,
    reservedPendingStarts,
    pendingKeys,
    stopping: _stopping.size,
    stoppingInstances,
    activeRequests,
    maintenanceRequests,
    nonDefaultInstances,
    defaultRunning,
    idleEvictable: !!evictionCandidate,
    idleEvictableCount: evictionCandidateCount,
    evictionCandidateKey: evictionCandidate?.key ? publicLsKey(evictionCandidate.key) : null,
    canStartNewNonDefault: !blockReason,
    blockReason,
    memoryGuard,
  };
}

export function getLsAdmissionStatus(proxy = null) {
  const key = proxyKey(proxy);
  const existing = _pool.get(key);
  const pending = _pending.has(key);
  const occupancy = poolOccupancy();
  const effectiveOccupancy = poolOccupancyWithPendingReservations({ excludeKey: key });
  const evictionCandidate = findIdleNonDefaultEvictionCandidate();
  const evictionCandidateCount = countIdleNonDefaultEvictionCandidates();
  const memoryGuard = getLsMemoryGuardStatus({ reservedStarts: activeSpawnReservationCount({ excludeKey: key }) });
  if (existing?.ready) {
    const maintenanceRequests = _maintenanceUses.get(key) || 0;
    return {
      ok: true,
      wouldStart: false,
      errorType: null,
      reason: 'already_running',
      key,
      poolSize: occupancy,
      effectivePoolSize: effectiveOccupancy,
      maxInstances: MAX_LS_INSTANCES,
      pending,
      port: existing.port || null,
      generation: existing.generation || null,
      activeRequests: existing.activeRequests || 0,
      maintenanceRequests,
      memoryGuard,
    };
  }
  if (pending) {
    return {
      ok: true,
      wouldStart: false,
      errorType: null,
      reason: 'start_pending',
      key,
      poolSize: occupancy,
      effectivePoolSize: effectiveOccupancy,
      maxInstances: MAX_LS_INSTANCES,
      pending,
      memoryGuard,
    };
  }
  if (key !== 'default' && LS_MEMORY_GUARD_ENABLED && memoryGuard.availableBytes != null && !memoryGuard.okToSpawn) {
    return {
      ok: false,
      wouldStart: true,
      errorType: 'ls_memory_guard',
      reason: 'memory_guard',
      key,
      poolSize: occupancy,
      effectivePoolSize: effectiveOccupancy,
      maxInstances: MAX_LS_INSTANCES,
      pending,
      memoryGuard,
    };
  }
  if (key !== 'default' && !hasLsPoolCapacityForStart(effectiveOccupancy, MAX_LS_INSTANCES, evictionCandidateCount)) {
    return {
      ok: false,
      wouldStart: true,
      errorType: 'ls_pool_exhausted',
      reason: 'pool_full_no_idle',
      key,
      poolSize: occupancy,
      effectivePoolSize: effectiveOccupancy,
      maxInstances: MAX_LS_INSTANCES,
      pending,
      memoryGuard,
    };
  }
  return {
    ok: true,
    wouldStart: true,
    errorType: null,
    reason: 'can_start',
    key,
    poolSize: occupancy,
    effectivePoolSize: effectiveOccupancy,
    maxInstances: MAX_LS_INSTANCES,
    pending,
    poolFull: effectiveOccupancy >= MAX_LS_INSTANCES,
    willEvict: key !== 'default' && effectiveOccupancy >= MAX_LS_INSTANCES && !!evictionCandidate,
    idleEvictableCount: evictionCandidateCount,
    evictionCandidateKey: key !== 'default' && effectiveOccupancy >= MAX_LS_INSTANCES ? evictionCandidate?.key || null : null,
    memoryGuard,
  };
}

async function waitForPoolCapacity(key, pendingStartSeq = Infinity) {
  const start = Date.now();
  let logged = false;
  while (poolOccupancyWithPendingReservations({ excludeKey: key, beforeSeq: pendingStartSeq }) >= MAX_LS_INSTANCES) {
    if (await evictLruIdleNonDefault()) continue;
    const remaining = LS_POOL_WAIT_MS - (Date.now() - start);
    if (remaining <= 0) {
      const err = lsPoolExhaustedError(`LS pool at cap (${MAX_LS_INSTANCES}) and no idle non-default instance became evictable within ${LS_POOL_WAIT_MS}ms`);
      recordAdmissionFailure('pool_capacity', key, err, {
        poolSize: poolOccupancy(),
        effectivePoolSize: poolOccupancyWithPendingReservations({ excludeKey: key, beforeSeq: pendingStartSeq }),
        maxInstances: MAX_LS_INSTANCES,
      });
      throw err;
    }
    if (!logged) {
      logged = true;
      recordAdmissionWait('pool_capacity', key, {
        poolSize: poolOccupancy(),
        effectivePoolSize: poolOccupancyWithPendingReservations({ excludeKey: key, beforeSeq: pendingStartSeq }),
        maxInstances: MAX_LS_INSTANCES,
        waitMs: LS_POOL_WAIT_MS,
      });
      log.info(`LS pool at cap (${MAX_LS_INSTANCES}); waiting up to ${LS_POOL_WAIT_MS}ms for an active non-default instance to go idle`);
    }
    await delay(Math.min(500, remaining));
    const existing = _pool.get(key);
    if (existing?.ready) return;
  }
}

function isDefaultBootstrapStart(key) {
  return key === 'default' && poolOccupancy() === 0;
}

async function waitForMemoryHeadroom(key, pendingStartSeq = Infinity) {
  // Bootstrap exception: if no LS exists yet, allow the default instance to
  // start so low-memory single-replica deployments do not fail closed before
  // we have an observed RSS estimate. Once any LS/stopping reservation exists,
  // default is guarded like every other instance.
  if (isDefaultBootstrapStart(key)) return;
  if (!LS_MEMORY_GUARD_ENABLED) return;
  const start = Date.now();
  let logged = false;
  while (true) {
    const snap = getLsMemoryGuardStatus({
      reservedStarts: activeSpawnReservationCount({ excludeKey: key, beforeSeq: pendingStartSeq }),
    });
    if (snap.availableBytes == null || snap.availableBytes >= snap.minAvailableBytes) return;
    const remaining = LS_POOL_WAIT_MS - (Date.now() - start);
    if (remaining <= 0) {
      const err = lsPoolExhaustedError(`LS memory guard blocked new instance ${key}: available=${snap.availableBytes} min=${snap.minAvailableBytes}`);
      err.type = 'ls_memory_guard';
      err.code = 'LS_MEMORY_GUARD';
      recordAdmissionFailure('memory_guard', key, err, { memoryGuard: snap });
      throw err;
    }
    if (!logged) {
      logged = true;
      recordAdmissionWait('memory_guard', key, { memoryGuard: snap, waitMs: LS_POOL_WAIT_MS });
      log.info(`LS memory guard delaying ${key}: available=${snap.availableBytes} min=${snap.minAvailableBytes}`);
    }
    await delay(Math.min(500, remaining));
  }
}

export function sweepIdleLanguageServers(now = Date.now()) {
  if (!LS_IDLE_TTL_MS) return { scanned: _pool.size, stopped: 0, ttlMs: LS_IDLE_TTL_MS };
  let stopped = 0;
  for (const [key, entry] of _pool) {
    if (key === 'default') continue;
    if (!entry?.ready) continue;
    if ((entry.activeRequests || 0) > 0) continue;
    if ((_maintenanceUses.get(key) || 0) > 0) continue;
    const last = entry.lastUsedAt || entry.startedAt || now;
    if (now - last < LS_IDLE_TTL_MS) continue;
    markIntentionalShutdown(entry);
    try { entry.process?.kill('SIGTERM'); } catch {}
    invalidateEntryForShutdown(entry);
    _pool.delete(key);
    stopped++;
    log.info(`LS idle reaper stopped ${key} after ${Math.round((now - last) / 1000)}s idle`);
  }
  return { scanned: _pool.size + stopped, stopped, ttlMs: LS_IDLE_TTL_MS };
}

function ensureIdleSweeper() {
  if (_idleSweepTimer || !LS_IDLE_SWEEP_MS) return;
  _idleSweepTimer = setInterval(() => {
    try { sweepIdleLanguageServers(); } catch (e) { log.warn(`LS idle reaper: ${e.message}`); }
  }, LS_IDLE_SWEEP_MS);
  try { _idleSweepTimer.unref(); } catch {}
}

function collectTrackedRssSnapshot(now = Date.now()) {
  const trackedPids = new Set();
  for (const entry of _pool.values()) {
    const pid = entry?.process?.pid;
    if (Number.isInteger(pid) && pid > 0) trackedPids.add(pid);
  }
  const pidKey = [...trackedPids].sort((a, b) => a - b).join(',');
  if (!trackedPids.size) {
    _rssSnapshot = { at: now, pidKey, byRootPid: new Map() };
    return _rssSnapshot.byRootPid;
  }
  if (_rssSnapshot.pidKey === pidKey && now - _rssSnapshot.at < RSS_SNAPSHOT_TTL_MS) return _rssSnapshot.byRootPid;
  if (process.platform === 'win32') {
    _rssSnapshot = { at: now, pidKey, byRootPid: new Map() };
    return _rssSnapshot.byRootPid;
  }

  try {
    const out = execSync('ps -e -o pid=,ppid=,rss=', { timeout: 3000, encoding: 'utf-8' });
    const childrenByParent = new Map();
    const rssByPid = new Map();
    for (const line of out.split('\n')) {
      const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
      if (!m) continue;
      const pid = parseInt(m[1], 10);
      const ppid = parseInt(m[2], 10);
      const rssKb = parseInt(m[3], 10);
      rssByPid.set(pid, rssKb);
      if (!childrenByParent.has(ppid)) childrenByParent.set(ppid, []);
      childrenByParent.get(ppid).push(pid);
    }

    const byRootPid = new Map();
    for (const rootPid of trackedPids) {
      let rssKb = 0;
      let processCount = 0;
      const seen = new Set();
      const stack = [rootPid];
      while (stack.length) {
        const pid = stack.pop();
        if (!Number.isInteger(pid) || seen.has(pid)) continue;
        seen.add(pid);
        if (rssByPid.has(pid)) {
          rssKb += rssByPid.get(pid) || 0;
          processCount++;
        }
        for (const child of (childrenByParent.get(pid) || [])) stack.push(child);
      }
      if (processCount > 0) byRootPid.set(rootPid, {
        rssKb,
        rssBytes: rssKb * 1024,
        processCount,
      });
    }
    _rssSnapshot = { at: now, pidKey, byRootPid };
  } catch (e) {
    _rssSnapshot = { at: now, pidKey, byRootPid: new Map() };
    log.debug(`LS RSS snapshot unavailable: ${e.message}`);
  }
  return _rssSnapshot.byRootPid;
}

function lsStatusConfig() {
  const memoryGuard = getLsMemoryGuardStatus();
  return {
    maxInstances: MAX_LS_INSTANCES,
    poolWaitMs: LS_POOL_WAIT_MS,
    idleTtlMs: LS_IDLE_TTL_MS,
    idleSweepMs: LS_IDLE_SWEEP_MS,
    estimatedRssBytesPerInstance: DEFAULT_LS_RSS_ESTIMATE_BYTES,
    effectiveEstimatedRssBytesPerInstance: memoryGuard.estimatedRssBytesPerInstance,
    systemMemoryBytes: totalmem(),
    detectedMemoryLimitBytes: detectMemoryLimitBytes(),
    memoryGuard,
  };
}

export function shouldPrewarmDefaultLs() {
  return process.env.LS_PREWARM_DEFAULT !== '0' && MAX_LS_INSTANCES > 1;
}

export function classifyLanguageServerStderr(line) {
  const s = String(line || '').trim();
  if (!s) return 'debug';
  if (/(?:^|\s)E\d{4}\s|\b(?:FATAL|PANIC|CRITICAL|ERROR|ERR)\b/i.test(s)) return 'error';
  if (/(?:^|\s)W\d{4}\s|\b(?:WARN|WARNING)\b/i.test(s)) return 'warn';
  if (/\b(?:failed|failure|exception|denied|refused|timed?\s*out|not found|cannot|could not|invalid|unavailable|crash|segmentation fault)\b/i.test(s)) return 'warn';
  return 'info';
}

// Pass only what the LS binary actually needs to its child env. Forwarding
// the full process.env exposed unrelated proxy secrets / build env / CI
// tokens to a binary we don't fully control. Allowlist covers HOME (asset
// paths), PATH (subprocess discovery), LANG/locale, TMPDIR, and the proxy
// vars that the LS reads to dial out.
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 trust roots — without these LS can fail to verify the upstream
  // Codeium endpoint on hardened hosts.
  'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
];

// Windows-only. The LS calls Go's os.UserConfigDir(), which reads %AppData%
// and aborts startup with "failed to determine user config directory:
// %AppData% is not defined" when the child env has no APPDATA. Everything
// else stays filtered: this build never links os.UserCacheDir or
// os.UserHomeDir (their Go error strings are absent from the binary),
// TEMP/TMP are already in the cross-platform list, and no LS code reads
// SystemRoot as an env key.
const LS_ENV_ALLOWLIST_WIN32 = ['APPDATA'];

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 ((options.platform ?? process.platform) === 'win32') {
    for (const key of LS_ENV_ALLOWLIST_WIN32) {
      if (source[key] != null && source[key] !== '') env[key] = source[key];
    }
  }
  // Fall back to /root only when HOME isn't already set (e.g. a systemd
  // unit without User=). VPS deployments already have HOME in env; forcing
  // /root broke macOS/Windows dev runs where LS expects the real $HOME.
  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) => {
    // 127.0.0.1 (not "localhost") — see grpc.js getSession() for the
    // autoSelectFamily=false rationale.
    const client = http2.connect(`http://127.0.0.1:${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://127.0.0.1:${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`);
}

/**
 * Spawn an LS instance for the given proxy (or no-proxy default).
 * Idempotent — returns the existing entry if one is already running.
 */
export async function ensureLs(proxy = null) {
  ensureIdleSweeper();
  const key = proxyKey(proxy);
  const existing = _pool.get(key);
  if (existing && existing.ready) {
    touchEntry(existing);
    return existing;
  }

  // Coalesce concurrent callers onto a single spawn. The chat handlers
  // call ensureLs(acct.proxy) on every request; before this guard, a burst
  // of requests for a never-seen proxy would spawn N LS processes that
  // all tried to bind the same _nextPort.
  const pending = _pending.get(key);
  if (pending) return pending;

  const pendingStartSeq = _nextPendingStartSeq++;
  _pendingStartSeq.set(key, pendingStartSeq);
  const promise = (async () => {
    let entry = null;
    let reservedByThisCall = false;
    let attemptedStart = false;
    await withStartAdmissionLock(async () => {
      await waitForPoolCapacity(key, pendingStartSeq);
      await waitForMemoryHeadroom(key, pendingStartSeq);
      const nowExisting = _pool.get(key);
      if (nowExisting?.ready) {
        touchEntry(nowExisting);
        entry = nowExisting;
        return;
      }
      entry = nowExisting || {
        key,
        proxy,
        startedAt: Date.now(),
        lastUsedAt: Date.now(),
        activeRequests: 0,
        ready: false,
        generation: randomUUID(),
        workspaceInit: null,
        sessionId: null,
      };
      if (!nowExisting) {
        reservedByThisCall = true;
        _pool.set(key, entry);
      }
    });
    if (entry?.ready) return entry;

    try {
      attemptedStart = true;
      recordStartAttempt(key, { poolSize: poolOccupancy(), maxInstances: MAX_LS_INSTANCES, reservedByThisCall });
      const isDefault = key === 'default';
      let port = isDefault ? DEFAULT_PORT : _nextPort++;

    // If something is already listening on the default port, NEVER adopt
    // blindly — even a probe-based gRPC signature is spoofable by any local
    // process serving HTTP/2 with a `server: *grpc*` header (verified). The
    // adoption flow used to send account API keys to whatever was listening,
    // which is unacceptable for a public-facing proxy. Instead, walk to the
    // next free port and spawn a fresh LS there. Operator gives up the
    // post-crash "adopt the orphan" convenience; in exchange, a malicious or
    // accidental local process can no longer receive credentials.
    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));
    }

    // Non-default ports: skip anything already bound. A PM2 restart can
    // race the old LS's TCP teardown — if we spawn while the dying
    // process still owns 42101, waitPortReady would succeed by connecting
    // to the corpse and every request would hang. Advance _nextPort until
    // we find a free slot.
    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 });

    // One-shot readable warning when the LS binary is missing — the generic
    // ENOENT from spawn leaves users guessing which file is expected.
    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 lines = data.toString().trim().split(/\r?\n/);
      for (const line of lines) {
        if (!line) continue;
        const level = classifyLanguageServerStderr(line);
        log[level](`[LS:${key}:stderr] ${line}`);
      }
    });
    proc.on('exit', (code, signal) => {
      log.warn(`LS instance ${key} exited: code=${code} signal=${signal}`);
      const current = _pool.get(key);
      const ownsEntry = current?.process === proc;
      const intentionalExit = isIntentionalShutdown(proc);
      if (!intentionalExit) {
        _admissionStats.lastFailure = admissionEvent({
          kind: 'process_exit',
          key: publicLsKey(key),
          errorType: 'ls_process_exit',
          code,
          signal,
          port,
          pid: proc.pid || null,
        });
      }
      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 = ownsEntry ? current : null;
      const goneGen = gone?.generation;
      const gonePort = gone?.port;
      if (ownsEntry) {
        _pool.delete(key);
        _maintenanceUses.delete(key);
      } else {
        log.debug(`Ignoring stale LS exit for ${key}; pool entry belongs to a newer generation`);
      }
      if (gonePort) {
        closeSessionForPort(gonePort);
        import('./conversation-pool.js').then(m => m.invalidateFor({ lsPort: gonePort, lsGeneration: goneGen })).catch(() => {});
      }

      // Auto-restart: respawn the LS after a brief backoff so pending
      // requests don't fail with ECONNRESET. Respects the retry cap and
      // tracks per-key restart attempts to avoid infinite loops on
      // permanent errors (e.g. missing binary, incompatible arch).
      // Skip when the exit was intentional (stopLanguageServer / restartLsForProxy).
      if (AUTO_RESTART_ENABLED && gone && !intentionalExit) {
        scheduleLsRestart(key, gone.proxy, gonePort);
      }
      clearIntentionalShutdown(proc);
    });
    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}`);
      }
      if (_pool.get(key)?.process === proc) {
        _pool.delete(key);
        _maintenanceUses.delete(key);
      }
    });

    Object.assign(entry, {
      process: proc, port, csrfToken: DEFAULT_CSRF,
      proxy, lastUsedAt: Date.now(), activeRequests: entry.activeRequests || 0, ready: false,
      // v2.0.25 LOW-1: per-spawn UUID so the conversation pool can tell a
      // new LS that landed on the same port apart from the dead one. Used
      // by checkout(expected={lsGeneration}) and invalidateFor({lsGeneration}).
      generation: entry.generation || randomUUID(),
      // One-shot Cascade workspace init promise. cascadeChat() awaits this so
      // the heavy InitializePanelState / AddTrackedWorkspace / UpdateWorkspaceTrust
      // trio only runs once per LS lifetime instead of once per request.
      workspaceInit: entry.workspaceInit || null,
      sessionId: entry.sessionId || null,
    });

      try {
        await waitPortReadyOrProcessExit(proc, port, 25000);
        entry.ready = true;
        entry.readyAt = Date.now();
        touchEntry(entry);
        recordStartSuccess(key, { port, pid: proc.pid || null, readyMs: entry.readyAt - entry.startedAt });
        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 {}
      if (_pool.get(key)?.process === proc) {
        _pool.delete(key);
        _maintenanceUses.delete(key);
      }
      _stopping.delete(key);
      throw err;
      }
    return entry;
    } catch (err) {
      if (reservedByThisCall && _pool.get(key) === entry && !entry?.process) {
        _pool.delete(key);
      }
      if (attemptedStart) recordStartFailure(key, err, { port: entry?.port || null, pid: entry?.process?.pid || null });
      throw err;
    }
  })();

  _pending.set(key, promise);
  try {
    return await promise;
  } finally {
    _pending.delete(key);
    _pendingStartSeq.delete(key);
  }
}

/**
 * Stop and remove the LS instance associated with a given proxy.
 * Used when a proxy is reassigned so the old egress no longer exists.
 */
export async function restartLsForProxy(proxy) {
  const key = proxyKey(proxy);
  const entry = _pool.get(key);
  if (!entry) return ensureLs(proxy);
  markIntentionalShutdown(entry);  // prevent auto-restart
  _stopping.set(key, { at: Date.now(), pid: entry?.process?.pid || null, reason: 'restart' });
  _pool.delete(key);
  _maintenanceUses.delete(key);
  if (entry?.port) {
    // v2.0.25 LOW-1: same-port LS replacement opens a window where stale
    // pool entries from the old LS could resume against the new LS's
    // session. Close the cached HTTP/2 session and invalidate this LS's
    // generation in the conversation pool synchronously, then spawn fresh.
    closeSessionForPort(entry.port);
    try {
      const m = await import('./conversation-pool.js');
      m.invalidateFor({ lsPort: entry.port, lsGeneration: entry.generation });
    } catch {}
  }
  try { entry?.process?.kill('SIGTERM'); } catch {}
  let how = await waitProcessExit(entry?.process, 1500);
  if (how === 'timeout') {
    try { entry?.process?.kill('SIGKILL'); } catch {}
    how = await waitProcessExit(entry?.process, 500);
  }
  _stopping.delete(key);
  log.info(`LS instance ${key} stopped for restart (${how})`);
  return ensureLs(proxy);
}

/**
 * Get the LS entry matching a proxy, or null if it hasn't been spawned.
 * Callers should `await ensureLs(proxy)` first — don't silently fall back
 * to the default LS, because that sends the request through the wrong
 * egress IP (Codeium will see the wrong source, invalidate the session,
 * and falsely mark the account expired).
 */
export function getLsFor(proxy) {
  const entry = _pool.get(proxyKey(proxy));
  if (entry) touchEntry(entry);
  return entry || null;
}

// ─── Auto-restart ─────────────────────────────────────────────

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;
}

/**
 * Look up an LS pool entry by its gRPC port. Used by WindsurfClient so it
 * can attach per-LS state (one-shot cascade workspace init, persistent
 * sessionId) without plumbing the entry through every call site.
 */
export function getLsEntryByPort(port) {
  for (const entry of _pool.values()) {
    if (entry.port === port) return entry;
  }
  return null;
}

/**
 * Iterate over live LS pool keys. Used by the dashboard binary-update
 * endpoint to restart every spawned instance after install-ls.sh
 * replaces the binary on disk.
 */
export function _poolKeys() {
  return [..._pool.keys()];
}

/**
 * Recover the proxy descriptor for a given pool key. Returns null for
 * the default no-proxy entry.
 */
export function getProxyByKey(key) {
  const entry = _pool.get(key);
  return entry?.proxy || null;
}

// ─── Backward-compat API ───────────────────────────────────

export function getLsPort() {
  return _pool.get('default')?.port || DEFAULT_PORT;
}
export function getCsrfToken() {
  return _pool.get('default')?.csrfToken || DEFAULT_CSRF;
}

export function configureLanguageServer(opts = {}) {
  _binaryPath = opts.binaryPath || process.env.LS_BINARY_PATH || _binaryPath;
  _apiServerUrl = opts.apiServerUrl || process.env.CODEIUM_API_URL || _apiServerUrl;
}

/**
 * Legacy entry point used by index.js — starts the default (no-proxy) LS.
 */
export async function startLanguageServer(opts = {}) {
  configureLanguageServer(opts);
  const def = await ensureLs(null);
  return { port: def.port, csrfToken: def.csrfToken };
}

/**
 * v2.0.85 (#127 123cek): scan host process table for orphan
 * `language_server_linux_x64` instances left over from previous runs
 * (e.g. self-update via `process.exit()` skipped the SIGTERM hook,
 * or a supervisor SIGKILL killed us before stopLanguageServer could run) and
 * kill them. Keeps long-running supervised deployments from accumulating
 * dead LS processes that hold their pool ports.
 *
 * Limited to processes whose argv[0] matches our `_binaryPath` (or
 * the legacy DEFAULT_BINARY) so unrelated `language_server_linux_x64`
 * binaries on the same host (rare) are not touched. No-op on Windows
 * since the LS binary is Linux-only there.
 *
 * Skips itself: PIDs we currently track in `_pool` (none yet at
 * startup, but called also from /self-update before exit).
 *
 * Best-effort: any error is logged but doesn't block startup.
 */
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 {
    // -e: every process; -o pid,args: pid + full argv (so we can match
    // the binary path). Cap output at a few hundred lines via head; LS
    // pids are typically small clusters, not thousands.
    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];
      // Match either the configured binary path OR the default path.
      // v2.0.88 (audit L-1): anchor the match to argv[0] so we don't
      // SIGTERM a `grep language_server_linux_x64` or a monitoring
      // agent whose argv mentions our binary path elsewhere.
      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() {
  // v2.0.25 LOW-1: tear down ALL conversation pool entries pinned to LSes
  // we're about to kill, so the dashboard restart path doesn't leak dead
  // cascade ids into the next LS's session window.
  const portsToClose = [];
  for (const [key, entry] of _pool) {
    markIntentionalShutdown(entry);  // prevent auto-restart
    _stopping.set(key, { at: Date.now(), pid: entry?.process?.pid || null, reason: 'stop' });
    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();
  _maintenanceUses.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(() => {});
  }
  setTimeout(() => {
    for (const [key, entry] of _stopping) {
      if (entry?.reason === 'stop') _stopping.delete(key);
    }
  }, 2500).unref?.();
}

/**
 * v2.0.88 (audit H-4): like `stopLanguageServer` but waits for each
 * spawned LS process to actually exit (capped per-process timeout)
 * before returning. Used by `dashboard /self-update` so `process.exit`
 * doesn't fire while children still hold their ports — preventing
 * orphan LS processes that would otherwise win a port-conflict race
 * against the freshly-restarted parent.
 *
 * Per-process wait cap defaults to 1.5s; total wait for many LSes is
 * bounded by Promise.allSettled. SIGKILL fallback if SIGTERM doesn't
 * land within the cap.
 */
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 });
    _stopping.set(key, { at: Date.now(), pid: entry?.process?.pid || null, reason: 'stop_wait' });
    markIntentionalShutdown(entry);
  }
  _pool.clear();
  _maintenanceUses.clear();
  await Promise.allSettled(procs.map(({ key, proc }) => new Promise((resolve) => {
    let settled = false;
    const finish = (how) => {
      if (settled) return;
      settled = true;
      _stopping.delete(key);
      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(/* timeoutMs */) {
  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');
  const now = Date.now();
  const rssByRootPid = collectTrackedRssSnapshot(now);
  const pool = getLsPoolSummary(now);
  const admissionStats = admissionStatsSnapshot();
  const instances = Array.from(_pool.entries()).map(([key, e]) => {
    const pid = e.process?.pid || null;
    const rss = pid ? rssByRootPid.get(pid) : null;
    const lastUsedAt = e.lastUsedAt || e.startedAt || null;
    return {
      key, port: e.port,
      pid,
      proxy: e.proxy ? `${e.proxy.host}:${e.proxy.port}` : null,
      startedAt: e.startedAt,
      lastUsedAt,
      idleMs: lastUsedAt ? Math.max(0, now - lastUsedAt) : null,
      activeRequests: e.activeRequests || 0,
      ready: e.ready,
      rssKb: rss?.rssKb ?? null,
      rssBytes: rss?.rssBytes ?? null,
      processCount: rss?.processCount ?? null,
    };
  });
  const totalRssBytes = instances.reduce((n, i) => n + (Number.isFinite(i.rssBytes) ? i.rssBytes : 0), 0);
  return {
    running: _pool.size > 0,
    pid: def?.process?.pid || null,
    port: def?.port || DEFAULT_PORT,
    startedAt: def?.startedAt || null,
    restartCount: 0,
    ...lsStatusConfig(),
    totalRssBytes: totalRssBytes || null,
    pool,
    admissionStats,
    instances,
  };
}