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
17 changes: 17 additions & 0 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,23 @@ jobs:
SOURCE="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).languages.find(l=>l.language==='rust').backend.source" "$TRANSCRIPTS/doctor.json")"
echo "backend.source=$SOURCE (expected $EXPECT)"
[ "$SOURCE" = "$EXPECT" ]
if [ "$LEG" = "omit-optional" ] && [ "$SLIM" = "true" ]; then
# The engine on this leg is the VSIX provisioned above at the
# checkout's pin, and doctor reads the version from that
# extraction's own package.json (#1066) β€” so it must name the pin.
# Releases up to 0.26.0 reported the CLI's compiled default instead;
# the assertion applies to everything published after.
CLI_VERSION="$(node -p "require(process.argv[1]).version" "$(npm root -g)/@debugmcp/mcp-debugger/package.json")"
FIXED="$(node -e 'const core=v=>v.split("-")[0].split(".").map(Number);const [a,b]=[core(process.argv[1]),core("0.26.0")];console.log(a[0]>b[0]||(a[0]===b[0]&&(a[1]>b[1]||(a[1]===b[1]&&a[2]>b[2]))))' "$CLI_VERSION")"
if [ "$FIXED" = "true" ]; then
PIN="$(node -p "require('./packages/codelldb-common/vendor-manifest.json').codelldb.version")"
ENGINE="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).languages.find(l=>l.language==='rust').backend.version" "$TRANSCRIPTS/doctor.json")"
echo "backend.version=$ENGINE (expected $PIN)"
[ "$ENGINE" = "$PIN" ]
else
echo "::notice::$CLI_VERSION predates the engine-version fix (#1066) β€” backend.version assertion skipped"
fi
fi

- name: Smoke cycle (mock)
run: |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1066.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**`doctor` reports the CodeLLDB that actually resolved, not the CLI's compiled default** β€” under a `CODELLDB_PATH` pointing into a raw VSIX extraction (the documented fallback for `--omit=optional` installs, and what the canary's own leg provisions) `doctor rust`, `cpp` and `cobol` named the version the CLI was built with, because the version lookup knew only the `version.json` the vendor script writes and then walked other installs' metadata before settling on the constant. The version now comes from the resolved binary's own root and nowhere else: its `version.json` (vendored copies, the `@debugmcp/codelldb-*` packages), else the VSIX's own `package.json` when it is CodeLLDB's (`name: vscode-lldb`); a symlinked `CODELLDB_PATH` is followed to where it points; a vendored copy with no readable metadata keeps the pin, and any other binary with none reports no version rather than a guess. The canary asserts the engine version on its `CODELLDB_PATH` leg for releases after 0.26.0. Thanks @gezerui888 for the fix and the regression tests (#1066)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Usage notes:
| dart | Dart SDK 3.x (`dart`), or Flutter (bundles its own Dart) | `dart debug_adapter` / `flutter debug-adapter` β€” the SDK's own DAP servers, behind a TCP-to-stdio bridge; nothing vendored | [dart.dev/get-dart](https://dart.dev/get-dart) (Windows `winget install Google.DartSDK`, macOS `brew install dart`, Linux the `dart` apt package) or Flutter; run `flutter --version` once so the Flutter tool cache is built | `DART_SDK` (alias `DART_PATH`), `FLUTTER_ROOT` (alias `FLUTTER_PATH`); a project's fvm pin (`.fvmrc`) is honoured |
| mock | β€” | β€” | nothing (testing adapter) | β€” |

CodeLLDB resolution order (rust, cpp and cobol): **vendored copy β†’ `CODELLDB_PATH` β†’ `@debugmcp/codelldb-<platform>` package** (npm installs exactly the one matching your platform as an optional dependency). Doctor's backend column shows which source won. If you installed with `--omit=optional`, set `CODELLDB_PATH` to a [CodeLLDB release](https://github.com/vadimcn/codelldb/releases) binary.
CodeLLDB resolution order (rust, cpp and cobol): **vendored copy β†’ `CODELLDB_PATH` β†’ `@debugmcp/codelldb-<platform>` package** (npm installs exactly the one matching your platform as an optional dependency). Doctor's backend column shows which source won, and the engine version read from that copy's own root: its `version.json` (vendored copies, the platform packages), else a raw VSIX extraction's `package.json`; a bare binary with neither shows no version. If you installed with `--omit=optional`, set `CODELLDB_PATH` to a [CodeLLDB release](https://github.com/vadimcn/codelldb/releases) binary (extract the `.vsix` and point at `extension/adapter/codelldb`).

## Failure signatures

Expand Down
79 changes: 48 additions & 31 deletions packages/codelldb-common/src/codelldb-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,48 +277,65 @@ export async function resolveCodeLLDBExecutable(options?: {
return resolved?.path ?? null;
}

/** The `name` of the upstream VSIX manifest (vadimcn/codelldb's package.json). */
const CODELLDB_VSIX_MANIFEST_NAME = 'vscode-lldb';

/** A parsed JSON object file, or null when it is unreadable, not JSON, or not an object. */
async function readJsonObject(file: string): Promise<Record<string, unknown> | null> {
try {
const parsed: unknown = JSON.parse(await fs.readFile(file, 'utf-8'));
return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}

/** The object's `version` when it is a non-empty string. */
function versionField(data: Record<string, unknown> | null): string | undefined {
const version = data?.version;
return typeof version === 'string' && version.length > 0 ? version : undefined;
}

/**
* Check if CodeLLDB is installed and get version
* The version of the CodeLLDB that resolves, read from that binary's own root
* and nowhere else (<root>/adapter/codelldb β†’ <root>): `version.json` β€” the
* vendor script writes it, the platform packages ship it β€” then the upstream
* VSIX manifest `package.json` when it is CodeLLDB's own, which is what a raw
* extraction under CODELLDB_PATH carries (issue #1066). Another install's
* metadata is never consulted, so the reported version cannot be
* misattributed: not a stale vendor tree under the cwd fallback, not the
* platform package's version.json when CODELLDB_PATH won, not a project's
* package.json above a hand-placed binary. A vendored copy with no readable metadata keeps the
* pin the vendor script fetched; any other source reports no version (null)
* rather than the compiled default.
*/
export async function getCodeLLDBVersion(options?: {
resolvePlatformPackageRoot?: (platformDir: string) => string | null;
}): Promise<string | null> {
const resolvePlatformPackageRoot =
options?.resolvePlatformPackageRoot ?? resolveCodeLLDBPlatformPackageRoot;
const codelldbPath = await resolveCodeLLDBExecutable({ resolvePlatformPackageRoot });

if (!codelldbPath) {
const resolved = await resolveCodeLLDBExecutableWithSource(options);
if (!resolved) {
return null;
}

const platformDir = getCodeLLDBPlatformDir(process.platform, process.arch);
if (!platformDir) {
return DEFAULT_CODELLDB_VERSION;
// A symlinked CODELLDB_PATH (~/.local/bin/codelldb β†’ the extraction) keeps
// its metadata where it points.
let binary = resolved.path;
try {
binary = await fs.realpath(resolved.path);
} catch {
// Read beside the path as given
}
const root = path.resolve(path.dirname(binary), '..');

// The version.json that describes the binary we actually resolved sits
// beside it (<root>/adapter/codelldb -> <root>/version.json) β€” probe that
// first so the reported version cannot be misattributed to a different
// install (e.g. a stale vendor tree under the cwd fallback).
const versionFileCandidates = [
path.resolve(path.dirname(codelldbPath), '..', 'version.json'),
...buildVendorCandidatePaths(defaultPackageRoot(), platformDir, 'version.json')
];

const platformPackageRoot = resolvePlatformPackageRoot(platformDir);
if (platformPackageRoot) {
versionFileCandidates.push(path.join(platformPackageRoot, 'version.json'));
const fromVersionJson = versionField(await readJsonObject(path.join(root, 'version.json')));
if (fromVersionJson) {
return fromVersionJson;
}

for (const versionFile of versionFileCandidates) {
try {
const versionData = await fs.readFile(versionFile, 'utf-8');
const parsed = JSON.parse(versionData);
return parsed.version || DEFAULT_CODELLDB_VERSION;
} catch {
// Continue to next candidate
}
const manifest = await readJsonObject(path.join(root, 'package.json'));
const fromManifest = manifest?.name === CODELLDB_VSIX_MANIFEST_NAME ? versionField(manifest) : undefined;
if (fromManifest) {
return fromManifest;
}

return DEFAULT_CODELLDB_VERSION; // Default version fallback
return resolved.source === 'vendored' ? DEFAULT_CODELLDB_VERSION : null;
}
199 changes: 185 additions & 14 deletions packages/codelldb-common/tests/codelldb-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { readFileSync } from 'node:fs';

const accessMock: Mock = vi.fn();
const readFileMock: Mock = vi.fn();
const realpathMock: Mock = vi.fn();

vi.mock('fs/promises', () => ({
access: (...args: unknown[]) => accessMock(...args),
readFile: (...args: unknown[]) => readFileMock(...args)
readFile: (...args: unknown[]) => readFileMock(...args),
realpath: (...args: unknown[]) => realpathMock(...args)
}));

import {
Expand Down Expand Up @@ -48,6 +50,9 @@ describe('codelldb-resolver', () => {
beforeEach(() => {
accessMock.mockReset();
readFileMock.mockReset();
// No symlinks unless a test says so
realpathMock.mockReset();
realpathMock.mockImplementation(async (p: string) => p);
// Hermetic: never let the host machine's CODELLDB_PATH leak into tests
vi.stubEnv('CODELLDB_PATH', '');
});
Expand Down Expand Up @@ -213,6 +218,23 @@ describe('codelldb-resolver', () => {
});

describe('getCodeLLDBVersion', () => {
// The version comes from the resolved binary's own root (<root>/adapter/codelldb):
// <root>/version.json (the vendor script's, the platform package's), then the
// upstream VSIX manifest <root>/package.json when it is CodeLLDB's own
// (issue #1066). Another install's metadata is never read: a vendored copy with
// no readable metadata keeps the pin, any other source reports no version.
const vsixManifest = (version: string): string =>
JSON.stringify({ name: 'vscode-lldb', publisher: 'vadimcn', version });

/** Serve `files` (path -> content; undefined = ENOENT) and ENOENT for any other path. */
function serveFiles(files: ReadonlyMap<string, string | undefined>): void {
readFileMock.mockImplementation(async (candidate: string) => {
const content = files.get(candidate);
if (content === undefined) throw new Error('ENOENT');
return content;
});
}

it('returns null when the executable cannot be resolved', async () => {
stubPlatform('win32');
accessMock.mockRejectedValue(new Error('ENOENT'));
Expand All @@ -228,39 +250,188 @@ describe('codelldb-resolver', () => {

await expect(getCodeLLDBVersion()).resolves.toBe('1.12.3');

expect(readFileMock).toHaveBeenCalledTimes(1);
const versionFile = readFileMock.mock.calls[0][0] as string;
expect(versionFile.endsWith(path.join('linux-x64', 'version.json'))).toBe(true);
});

it('skips a malformed version.json and reads the next candidate', async () => {
it('a vendored copy with a malformed version.json and no manifest reports the pin, reading nothing of another install', async () => {
stubPlatform('linux', 'x64');
accessMock.mockResolvedValue(undefined);
readFileMock
.mockResolvedValueOnce('not-json{')
.mockResolvedValueOnce(JSON.stringify({ version: '9.9.9' }));
readFileMock.mockResolvedValueOnce('not-json{').mockRejectedValue(new Error('ENOENT'));

await expect(getCodeLLDBVersion()).resolves.toBe(DEFAULT_CODELLDB_VERSION);

// version.json, then package.json, both beside the resolved binary β€” not the
// other vendor candidates, not the platform package
const vendorDir = path.dirname(path.dirname(accessMock.mock.calls[0][0] as string));
expect(readFileMock.mock.calls.map((call) => call[0])).toEqual([
path.join(vendorDir, 'version.json'),
path.join(vendorDir, 'package.json')
]);
});

it('a vendored version.json without a version field falls through to the VSIX manifest beside it', async () => {
stubPlatform('darwin', 'arm64');
accessMock.mockResolvedValue(undefined);
readFileMock.mockResolvedValueOnce('{}').mockResolvedValueOnce(vsixManifest('1.12.3'));

await expect(getCodeLLDBVersion()).resolves.toBe('9.9.9');
await expect(getCodeLLDBVersion()).resolves.toBe('1.12.3');
expect(readFileMock).toHaveBeenCalledTimes(2);
});

it('falls back to DEFAULT_CODELLDB_VERSION when no version.json is readable', async () => {
it('falls back to DEFAULT_CODELLDB_VERSION for a vendored copy with no readable metadata', async () => {
stubPlatform('win32');
accessMock.mockResolvedValue(undefined);
readFileMock.mockRejectedValue(new Error('ENOENT'));

await expect(
getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null })
).resolves.toBe(DEFAULT_CODELLDB_VERSION);
// Sibling-of-resolved-binary candidate + the 4 vendor candidates
expect(readFileMock).toHaveBeenCalledTimes(5);
// The two files beside the binary and nothing else
expect(readFileMock).toHaveBeenCalledTimes(2);
});

it('falls back to DEFAULT_CODELLDB_VERSION when version.json lacks a version field', async () => {
stubPlatform('darwin', 'arm64');
accessMock.mockResolvedValue(undefined);
readFileMock.mockResolvedValue('{}');
describe('under CODELLDB_PATH (a raw VSIX extraction, issue #1066)', () => {
const extensionRoot = path.resolve('/raw-vsix/extension');
const executable = path.join(extensionRoot, 'adapter', 'codelldb');

/** CODELLDB_PATH names the extraction's binary; the vendor candidates do not exist. */
function stubExtraction(versionJson: string | undefined, packageJson: string | undefined): void {
stubPlatform('linux', 'x64');
vi.stubEnv('CODELLDB_PATH', executable);
accessMock.mockImplementation(async (candidate: string) => {
if (candidate !== executable) throw new Error('ENOENT');
});
serveFiles(new Map([
[path.join(extensionRoot, 'version.json'), versionJson],
[path.join(extensionRoot, 'package.json'), packageJson]
]));
}

await expect(getCodeLLDBVersion()).resolves.toBe(DEFAULT_CODELLDB_VERSION);
it.each([
{
name: 'reads the VSIX manifest when there is no version.json',
versionJson: undefined,
packageJson: vsixManifest('1.11.8'),
expected: '1.11.8'
},
{
name: 'keeps version.json ahead of the manifest',
versionJson: '{"version":"1.11.7"}',
packageJson: vsixManifest('1.11.8'),
expected: '1.11.7'
},
{
name: 'falls through to the manifest when version.json has no version field',
versionJson: '{}',
packageJson: vsixManifest('1.11.8'),
expected: '1.11.8'
},
{
name: 'falls through to the manifest when version.json is malformed',
versionJson: '{invalid',
packageJson: vsixManifest('1.11.8'),
expected: '1.11.8'
},
{
name: 'reports no version when the manifest is malformed',
versionJson: undefined,
packageJson: '{invalid',
expected: null
},
{
name: 'reports no version when there is no metadata at all',
versionJson: undefined,
packageJson: undefined,
expected: null
},
{
name: 'reports no version when the manifest has no version',
versionJson: undefined,
packageJson: vsixManifest(''),
expected: null
},
{
name: "reports no version when the package.json beside the binary is not CodeLLDB's",
versionJson: undefined,
packageJson: '{"name":"my-app","version":"0.4.2"}',
expected: null
}
])('$name', async ({ versionJson, packageJson, expected }) => {
stubExtraction(versionJson, packageJson);

await expect(
getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null })
).resolves.toBe(expected);

// Never the compiled default, never another install's metadata: every read
// is beside the resolved binary.
expect(readFileMock.mock.calls.length).toBeGreaterThan(0);
for (const call of readFileMock.mock.calls) {
expect(path.dirname(call[0] as string)).toBe(extensionRoot);
}
});

it('follows a symlinked CODELLDB_PATH to the extraction it points into', async () => {
const link = path.resolve('/usr/local/bin/codelldb');
stubExtraction(undefined, vsixManifest('1.11.8'));
vi.stubEnv('CODELLDB_PATH', link);
accessMock.mockImplementation(async (candidate: string) => {
if (candidate !== link) throw new Error('ENOENT');
});
realpathMock.mockImplementation(async (candidate: string) => (candidate === link ? executable : candidate));

await expect(
getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null })
).resolves.toBe('1.11.8');
expect(readFileMock.mock.calls.map((call) => call[0])).toEqual([
path.join(extensionRoot, 'version.json'),
path.join(extensionRoot, 'package.json')
]);
});

it('reads beside the path as given when it cannot be resolved', async () => {
stubExtraction(undefined, vsixManifest('1.11.8'));
realpathMock.mockRejectedValue(new Error('EACCES'));

await expect(
getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null })
).resolves.toBe('1.11.8');
});
});

describe('under the @debugmcp/codelldb-<platform> package', () => {
const packageRoot = path.resolve('/pkg/node_modules/@debugmcp/codelldb-linux-x64');
const executable = path.join(packageRoot, 'adapter', 'codelldb');

/** Only the platform package's binary exists; its npm manifest is not CodeLLDB's. */
function stubPlatformPackage(versionJson: string | undefined): void {
stubPlatform('linux', 'x64');
accessMock.mockImplementation(async (candidate: string) => {
if (candidate !== executable) throw new Error('ENOENT');
});
serveFiles(new Map([
[path.join(packageRoot, 'version.json'), versionJson],
[path.join(packageRoot, 'package.json'), '{"name":"@debugmcp/codelldb-linux-x64","version":"1.12.4"}']
]));
}

it('reads the version.json the package ships', async () => {
stubPlatformPackage('{"version":"1.12.3"}');

await expect(
getCodeLLDBVersion({ resolvePlatformPackageRoot: () => packageRoot })
).resolves.toBe('1.12.3');
});

it("reports no version without it β€” the npm manifest's version is the package's, not the engine's", async () => {
stubPlatformPackage(undefined);

await expect(
getCodeLLDBVersion({ resolvePlatformPackageRoot: () => packageRoot })
).resolves.toBeNull();
});
});
});

Expand Down
Loading
Loading