diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c5726c..6ef4b1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **vendor-codelldb.js can no longer die silently with exit 0** — the script-level root cause behind #389 (the Docker workaround shipped in v0.24.2 stands): a stalled extract-zip promise drained the event loop and Node exited 0 with no failure output. Extraction now runs under a watchdog (default 120 s, `CODELLDB_EXTRACT_TIMEOUT_MS`) whose pending timer keeps the event loop alive and converts a stall into a normal retry/failure, and a premature-exit guard forces exit code 1 with a requested/completed/unresolved-platforms diagnostic if the process would otherwise exit 0 before vendoring finished (#389) + ## [0.24.2] - 2026-08-19 ### Fixed diff --git a/docs/rust-debugging.md b/docs/rust-debugging.md index 9809afca..de36420f 100644 --- a/docs/rust-debugging.md +++ b/docs/rust-debugging.md @@ -46,6 +46,7 @@ SKIP_ADAPTER_VENDOR=true pnpm install - `CODELLDB_VENDOR_ALL=false`: opt out of the "vendor every platform" default and fall back to host-only downloads - `CODELLDB_VENDOR_LOCAL_ONLY=true`: disable network downloads entirely and fail if the requested platform isn't already vendored (used by Docker builds that copy pre-fetched artifacts) - `CODELLDB_KEEP_TEMP=true`: retain the downloaded VSIX and extracted temp folders for inspection +- `CODELLDB_EXTRACT_TIMEOUT_MS`: watchdog for VSIX extraction (default `120000`); a stalled unzip is aborted, retried, and surfaced as a failure instead of dying silently - `SKIP_ADAPTER_VENDOR=true`: opt out entirely (used by CI jobs that pre-bake artifacts) ### Troubleshooting vendoring diff --git a/packages/codelldb-common/scripts/vendor-codelldb.js b/packages/codelldb-common/scripts/vendor-codelldb.js index 0da0f16c..d08c33e1 100644 --- a/packages/codelldb-common/scripts/vendor-codelldb.js +++ b/packages/codelldb-common/scripts/vendor-codelldb.js @@ -12,13 +12,14 @@ * - CODELLDB_VENDOR_ALL: Set to 'true' to vendor all platforms in CI, or 'false' for current-only locally * - CODELLDB_FORCE_REBUILD: Set to 'true' to force re-vendor * - CODELLDB_VENDOR_LOCAL_ONLY: Set to 'true' to forbid downloads (use existing artifacts only) + * - CODELLDB_EXTRACT_TIMEOUT_MS: Watchdog for VSIX extraction (default: 120000) */ import fs from 'fs/promises'; import path from 'path'; import { Readable, Transform } from 'stream'; import { pipeline } from 'stream/promises'; -import { createWriteStream, createReadStream, readFileSync } from 'fs'; +import { createWriteStream, createReadStream, readFileSync, realpathSync } from 'fs'; import extractZip from 'extract-zip'; import ProgressBar from 'progress'; import { fileURLToPath } from 'url'; @@ -49,6 +50,14 @@ const IS_CI = process.env.CI === 'true'; const SKIP_VENDOR = process.env.SKIP_ADAPTER_VENDOR === 'true'; const KEEP_TEMP = process.env.CODELLDB_KEEP_TEMP === 'true'; const LOCAL_ONLY = process.env.CODELLDB_VENDOR_LOCAL_ONLY === 'true'; +const parsedExtractTimeout = Number(process.env.CODELLDB_EXTRACT_TIMEOUT_MS); +const EXTRACT_TIMEOUT_MS = + Number.isFinite(parsedExtractTimeout) && parsedExtractTimeout > 0 ? parsedExtractTimeout : 120000; +// Test-only hook (issue #389 regression coverage): simulate a fully drained +// event loop without touching the network. This must live in the script (not +// a test seam) because the premature-exit guard it exercises is registered +// only when the script is invoked directly as a whole process. +const TEST_SIMULATE_DRAIN = process.env.CODELLDB_TEST_SIMULATE_DRAIN === 'true'; const RELEASE_BASE_URLS = [ process.env.CODELLDB_RELEASE_BASE?.replace(/\/$/, '') || 'https://github.com/vadimcn/vscode-lldb/releases/download', @@ -414,6 +423,43 @@ async function downloadFile(url, destPath, maxRetries = 3) { } } +/** + * Extract a VSIX with a watchdog timer (issue #389). + * + * extract-zip's promise settles only on yauzl 'close'/'error'; a stalled entry + * pump leaves it forever pending with nothing else on the event loop, so Node + * drains and exits 0 before any failure path runs. The pending watchdog timer + * keeps the event loop alive for the whole extraction window, and converts a + * stall into a rejection that flows through the normal retry/failure paths. + * + * `opts.extractFn` / `opts.timeoutMs` are test seams (unit tests inject a + * stalling extractor and a short timeout); production callers pass neither. + */ +async function extractVsixWithWatchdog(vsixPath, destDir, vsixName, opts = {}) { + const extractFn = opts.extractFn ?? extractZip; + const timeoutMs = opts.timeoutMs ?? EXTRACT_TIMEOUT_MS; + const work = extractFn(vsixPath, { dir: destDir }); + // The abandoned extraction may still reject after the watchdog fires; + // swallow it so it cannot surface as a fatal unhandledRejection later. + work.catch(() => {}); + let timer; + const watchdog = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error( + `Extraction of ${vsixName} did not complete within ${timeoutMs}ms ` + + `(likely a stalled unzip stream - issue #389). ` + + `Re-run with CODELLDB_KEEP_TEMP=true to inspect ${destDir}, ` + + `or raise CODELLDB_EXTRACT_TIMEOUT_MS if this machine is just slow.` + )); + }, timeoutMs); + }); + try { + await Promise.race([work, watchdog]); + } finally { + clearTimeout(timer); + } +} + /** * Extract VSIX and copy required files */ @@ -442,8 +488,10 @@ async function extractAndCopyFiles(vsixPath, platform, platformInfo, vsixName) { // Extract VSIX (which is a zip file) log(`Extracting ${vsixName}...`); - await extractZip(vsixPath, { dir: tempExtractDir }); - + const extractStartedAt = Date.now(); + await extractVsixWithWatchdog(vsixPath, tempExtractDir, vsixName); + log(`Extracted ${vsixName} in ${Date.now() - extractStartedAt}ms`); + // Target directories for adapter and lldb const targetAdapterDir = path.join(VENDOR_DIR, platformInfo.targetDir, 'adapter'); const targetLldbDir = path.join(VENDOR_DIR, platformInfo.targetDir, 'lldb'); @@ -557,8 +605,13 @@ async function isAlreadyVendored(platform, platformInfo) { * Download and extract CodeLLDB for a specific platform */ async function downloadAndExtract(platform) { + if (TEST_SIMULATE_DRAIN) { + log(`TEST HOOK: simulating stalled vendoring for ${platform} (event-loop drain, issue #389)`); + await new Promise(() => {}); + } + const platformInfo = PLATFORMS[platform]; - + if (!platformInfo) { logWarn(`Unsupported platform: ${platform}`); return false; @@ -621,6 +674,9 @@ async function downloadAndExtract(platform) { } catch (error) { lastError = error; logWarn(`Attempt with ${vsixName} via ${baseUrl} failed: ${error.message}`); + if (error?.stack) { + logWarn(error.stack); + } await invalidateCacheEntry(vsixName).catch(() => {}); } finally { if (KEEP_TEMP) { @@ -710,6 +766,9 @@ async function main() { // Check if vendoring should be skipped if (SKIP_VENDOR) { log('Skipping vendoring (SKIP_ADAPTER_VENDOR=true)'); + // Must be marked complete BEFORE process.exit: the premature-exit guard + // honors process.exitCode mutations made inside 'exit' listeners. + runState.completedNormally = true; process.exit(0); } @@ -751,10 +810,11 @@ async function main() { // Determine which platforms to vendor const selectedPlatforms = determinePlatforms(); - + runState.requested = selectedPlatforms; + log(`Platforms to vendor: ${selectedPlatforms.join(', ')}\n`); - - const results = []; + + const results = runState.results; for (const platform of selectedPlatforms) { const success = await downloadAndExtract(platform); results.push({ platform, success }); @@ -802,18 +862,53 @@ async function main() { } } -const invokedDirectly = Boolean(process.argv[1] && path.resolve(process.argv[1]) === __filename); +// Tracks run progress so the premature-exit guard can tell a finished run from +// one whose event loop drained mid-vendoring (issue #389). +const runState = { requested: [], results: [], completedNormally: false }; + +function resolveReal(p) { + try { + return realpathSync(p); + } catch { + return path.resolve(p); + } +} + +const invokedDirectly = Boolean( + process.argv[1] && resolveReal(process.argv[1]) === resolveReal(__filename) +); // Run if called directly if (invokedDirectly) { - main().catch(error => { - logError(`Fatal error: ${error.message}`); - if (error?.stack) { - logError(error.stack); + // Safety net: if an async operation stalls and the event loop drains, Node + // exits 0 without main() ever settling. Force a diagnostic + exit code 1. + process.on('exit', (code) => { + if (code === 0 && !runState.completedNormally) { + const done = runState.results.filter(r => r.success).map(r => r.platform); + const unresolved = runState.requested.filter(p => !done.includes(p)); + logError('Premature exit: process is exiting with code 0 before vendoring finished (issue #389).'); + logError( + `Requested: ${runState.requested.join(', ') || ''}; ` + + `completed: ${done.join(', ') || ''}; unresolved: ${unresolved.join(', ') || ''}` + ); + logError('An async operation likely stalled and the event loop drained. Forcing exit code 1.'); + process.exitCode = 1; } - logWarn('Rust debugging will not be available'); - process.exitCode = 1; }); + + main() + .then(() => { + runState.completedNormally = true; + }) + .catch(error => { + logError(`Fatal error: ${error.message}`); + if (error?.stack) { + logError(error.stack); + } + logWarn('Rust debugging will not be available'); + runState.completedNormally = true; + process.exitCode = 1; + }); } -export { downloadAndExtract, PLATFORMS, CODELLDB_VERSION }; +export { downloadAndExtract, extractVsixWithWatchdog, PLATFORMS, CODELLDB_VERSION }; diff --git a/packages/codelldb-common/tests/vendor-codelldb-script.test.ts b/packages/codelldb-common/tests/vendor-codelldb-script.test.ts new file mode 100644 index 00000000..cdda4ae9 --- /dev/null +++ b/packages/codelldb-common/tests/vendor-codelldb-script.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for scripts/vendor-codelldb.js exit-code contract (issue #389) + * + * The observed defect: a mid-extraction stall left extract-zip's promise + * forever pending, the event loop drained, and Node exited 0 without any + * failure output. These tests pin the whole-process exit codes via spawned + * children (network-free, using the script's drain hook) and unit-test the + * extraction watchdog via direct import with injected seams. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const scriptPath = path.resolve(__dirname, '../scripts/vendor-codelldb.js'); + +type ExtractOpts = { extractFn?: (p: string, o: { dir: string }) => Promise; timeoutMs?: number }; +const { extractVsixWithWatchdog } = (await import(pathToFileURL(scriptPath).href)) as { + extractVsixWithWatchdog: (vsixPath: string, destDir: string, vsixName: string, opts?: ExtractOpts) => Promise; +}; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-vendor-script-test-')); + +interface RunResult { + status: number | null; + output: string; +} + +function runScript(extraEnv: Record): RunResult { + const result = spawnSync(process.execPath, [scriptPath], { + env: { + ...process.env, + // Neutralize anything the parent environment (dev box or CI runner) + // could leak into platform selection or exit semantics. + CI: '', + SKIP_ADAPTER_VENDOR: '', + CODELLDB_PLATFORMS: '', + CODELLDB_VENDOR_ALL: '', + CODELLDB_FORCE_REBUILD: '', + CODELLDB_VENDOR_LOCAL_ONLY: '', + CODELLDB_TEST_SIMULATE_DRAIN: '', + CODELLDB_CACHE_DIR: path.join(tempDir, 'cache'), + ...extraEnv + }, + encoding: 'utf8', + // Kept below the unit project's 15s testTimeout: a hung child must be + // killed (and fail on real evidence) inside the test's own budget. + timeout: 10_000 + }); + return { status: result.status, output: `${result.stdout ?? ''}\n${result.stderr ?? ''}` }; +} + +afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('vendor-codelldb.js exit codes (spawned)', () => { + it('exits 0 when vendoring is skipped via SKIP_ADAPTER_VENDOR', () => { + const { status, output } = runScript({ SKIP_ADAPTER_VENDOR: 'true' }); + expect(output).toContain('Skipping vendoring'); + expect(output).not.toContain('Premature exit'); + expect(status).toBe(0); + }); + + it('exits 1 with a diagnostic when the event loop drains mid-vendoring (issue #389 repro)', () => { + const { status, output } = runScript({ + CODELLDB_TEST_SIMULATE_DRAIN: 'true', + CODELLDB_PLATFORMS: 'linux-x64' + }); + expect(output).toContain('Premature exit'); + expect(output).toContain('unresolved: linux-x64'); + expect(status).toBe(1); + }); + + it('exits 1 through the failure summary (not the guard) on a normal failure', () => { + const { status, output } = runScript({ + CODELLDB_VENDOR_LOCAL_ONLY: 'true', + CODELLDB_FORCE_REBUILD: 'true', + CODELLDB_PLATFORMS: 'linux-x64' + }); + expect(output).toContain('Failed to vendor: linux-x64'); + // The guard must not double-report a failure the summary already surfaced. + expect(output).not.toContain('Premature exit'); + expect(status).toBe(1); + }); +}); + +describe('extractVsixWithWatchdog (imported)', () => { + it('converts a stalled extraction into a rejection after the timeout', async () => { + // extract-zip's floating-promise failure mode, injected via the seam. + const stallingExtract = () => new Promise(() => {}); + await expect( + extractVsixWithWatchdog( + path.join(tempDir, 'missing.vsix'), + path.join(tempDir, 'out'), + 'test.vsix', + { extractFn: stallingExtract, timeoutMs: 100 } + ) + ).rejects.toThrow(/did not complete within 100ms/); + }); + + it('resolves on a successful extraction and clears the watchdog timer', async () => { + // Minimal valid zip: the 22-byte end-of-central-directory record. + const emptyZip = Buffer.concat([Buffer.from('504b0506', 'hex'), Buffer.alloc(18)]); + const zipPath = path.join(tempDir, 'empty.zip'); + fs.writeFileSync(zipPath, emptyZip); + await expect( + extractVsixWithWatchdog(zipPath, path.join(tempDir, 'empty-out'), 'empty.zip') + ).resolves.toBeUndefined(); + // A leaked watchdog timer would keep the fork alive past the suite; the + // clean resolve above plus normal worker shutdown covers it. + }); + + it('registers no exit listener when merely imported', () => { + // The premature-exit guard is scoped to direct invocation; the static + // import at the top of this file must not have installed it. + const listeners = process.listeners('exit').map(String); + for (const src of listeners) { + expect(src).not.toContain('Premature exit'); + } + }); +});