Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/rust-debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 110 additions & 15 deletions packages/codelldb-common/scripts/vendor-codelldb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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(', ') || '<not yet determined>'}; ` +
`completed: ${done.join(', ') || '<none>'}; unresolved: ${unresolved.join(', ') || '<unknown>'}`
);
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 };
125 changes: 125 additions & 0 deletions packages/codelldb-common/tests/vendor-codelldb-script.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>; timeoutMs?: number };
const { extractVsixWithWatchdog } = (await import(pathToFileURL(scriptPath).href)) as {
extractVsixWithWatchdog: (vsixPath: string, destDir: string, vsixName: string, opts?: ExtractOpts) => Promise<void>;
};

const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-vendor-script-test-'));

interface RunResult {
status: number | null;
output: string;
}

function runScript(extraEnv: Record<string, string>): 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<void>(() => {});
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');
}
});
});
Loading