Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
5 changes: 5 additions & 0 deletions extensions/vscode/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export const workspace = {
onDidCreateFiles: noopReturn({ dispose: noop }),
onDidSaveTextDocument: noopReturn({ dispose: noop }),
fs: { stat: noop, readFile: noop, readDirectory: noop },
asRelativePath: (pathOrUri: any) => {
const p = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri?.fsPath ?? String(pathOrUri);
return p;
},
updateWorkspaceFolders: () => true,
};

export const window = {
Expand Down
2 changes: 2 additions & 0 deletions extensions/vscode/esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const testSuiteConfig = {
'test/suite/bridge.integration.test.ts',
'test/suite/copydb-e2e.integration.test.ts',
'test/suite/extension.integration.test.ts',
'test/suite/file-watcher-stability.integration.test.ts',
'test/suite/mcp-completion-e2e.integration.test.ts',
'test/suite/mcp-prompt-e2e.integration.test.ts',
'test/suite/mcp-resource-e2e.integration.test.ts',
'test/suite/mcp-server.integration.test.ts',
Expand Down
4 changes: 2 additions & 2 deletions extensions/vscode/src/bridge/database-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ export class DatabaseWatcher extends DisposableObject {
const dbRoot = ymlPath.replace(/\/codeql-database\.yml$/, '');
if (!this.knownDatabases.has(dbRoot)) {
this.knownDatabases.add(dbRoot);
this.logger.info(`Database discovered: ${dbRoot}`);
this.logger.info(`Database discovered: ${vscode.workspace.asRelativePath(dbRoot)}`);
Comment thread
data-douser marked this conversation as resolved.
this._onDidChange.fire();
}
}

private handleDatabaseRemoved(ymlPath: string): void {
const dbRoot = ymlPath.replace(/\/codeql-database\.yml$/, '');
if (this.knownDatabases.delete(dbRoot)) {
this.logger.info(`Database removed: ${dbRoot}`);
this.logger.info(`Database removed: ${vscode.workspace.asRelativePath(dbRoot)}`);
this._onDidChange.fire();
}
}
Expand Down
4 changes: 2 additions & 2 deletions extensions/vscode/src/bridge/query-results-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class QueryResultsWatcher extends DisposableObject {
const bqrsWatcher = vscode.workspace.createFileSystemWatcher('**/*.bqrs');
this.push(bqrsWatcher);
bqrsWatcher.onDidCreate((uri) => {
this.logger.info(`Query result (BQRS) created: ${uri.fsPath}`);
this.logger.info(`Query result (BQRS) created: ${vscode.workspace.asRelativePath(uri)}`);
this._onDidChange.fire();
});

Expand All @@ -36,7 +36,7 @@ export class QueryResultsWatcher extends DisposableObject {
);
this.push(sarifWatcher);
sarifWatcher.onDidCreate((uri) => {
this.logger.info(`Query result (SARIF) created: ${uri.fsPath}`);
this.logger.info(`Query result (SARIF) created: ${vscode.workspace.asRelativePath(uri)}`);
this._onDidChange.fire();
});

Expand Down
54 changes: 51 additions & 3 deletions extensions/vscode/src/codeql/cli-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const KNOWN_LOCATIONS = [
export class CliResolver extends DisposableObject {
private cachedPath: string | undefined | null = null; // null = not yet resolved
private cachedVersion: string | undefined;
private resolvePromise: Promise<string | undefined> | null = null;

/**
* Monotonically increasing generation counter, bumped by `invalidateCache()`.
* Used to discard results from in-flight `doResolve()` calls that started
* before the most recent invalidation.
*/
private _generation = 0;

constructor(
private readonly logger: Logger,
Expand All @@ -57,12 +65,42 @@ export class CliResolver extends DisposableObject {
return this.cachedPath;
}

// Return the in-flight promise if a resolution is already in progress
if (this.resolvePromise) {
return this.resolvePromise;
}

this.resolvePromise = this.doResolve();
try {
return await this.resolvePromise;
} finally {
this.resolvePromise = null;
}
}

/** Internal resolution logic. Called at most once per cache cycle. */
private async doResolve(): Promise<string | undefined> {
Comment thread
data-douser marked this conversation as resolved.
const startGeneration = this._generation;
this.logger.debug('Resolving CodeQL CLI path...');

/**
* Check whether the cache was invalidated while an async operation
* was in flight. If so, discard any version that `validateBinary()`
* may have written and bail out immediately.
*/
const isStale = (): boolean => {
if (this._generation !== startGeneration) {
this.cachedVersion = undefined;
return true;
}
return false;
};

// Strategy 1: CODEQL_PATH env var
const envPath = process.env.CODEQL_PATH;
if (envPath) {
const validated = await this.validateBinary(envPath);
if (isStale()) return undefined;
if (validated) {
this.logger.info(`CodeQL CLI found via CODEQL_PATH: ${envPath}`);
this.cachedPath = envPath;
Expand All @@ -73,14 +111,21 @@ export class CliResolver extends DisposableObject {

// Strategy 2: which/command -v
const whichPath = await this.resolveFromPath();
if (isStale()) return undefined;
if (whichPath) {
this.logger.info(`CodeQL CLI found on PATH: ${whichPath}`);
this.cachedPath = whichPath;
return whichPath;
const validated = await this.validateBinary(whichPath);
if (isStale()) return undefined;
if (validated) {
this.logger.info(`CodeQL CLI found on PATH: ${whichPath}`);
this.cachedPath = whichPath;
return whichPath;
}
this.logger.warn(`Found 'codeql' on PATH at '${whichPath}' but it failed validation.`);
}

// Strategy 3: vscode-codeql managed distribution
const distPath = await this.resolveFromVsCodeDistribution();
if (isStale()) return undefined;
if (distPath) {
this.logger.info(`CodeQL CLI found via vscode-codeql distribution: ${distPath}`);
this.cachedPath = distPath;
Expand All @@ -90,6 +135,7 @@ export class CliResolver extends DisposableObject {
// Strategy 4: known filesystem locations
for (const location of KNOWN_LOCATIONS) {
const validated = await this.validateBinary(location);
if (isStale()) return undefined;
if (validated) {
this.logger.info(`CodeQL CLI found at known location: ${location}`);
this.cachedPath = location;
Expand All @@ -106,6 +152,8 @@ export class CliResolver extends DisposableObject {
invalidateCache(): void {
this.cachedPath = null;
this.cachedVersion = undefined;
this.resolvePromise = null;
this._generation++;
}
Comment thread
data-douser marked this conversation as resolved.

/** Check if a path exists and responds to `--version`. */
Expand Down
18 changes: 8 additions & 10 deletions extensions/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,14 @@ export async function activate(
const queryWatcher = new QueryResultsWatcher(storagePaths, logger);
disposables.push(dbWatcher, queryWatcher);

// When databases or query results change, rebuild the environment and
// notify the MCP provider that the server definition has changed.
dbWatcher.onDidChange(() => {
envBuilder.invalidate();
mcpProvider.fireDidChange();
});
queryWatcher.onDidChange(() => {
envBuilder.invalidate();
mcpProvider.fireDidChange();
});
// File-content changes (new databases, query results) do NOT require
// a new MCP server definition. The running server discovers files on
// its own through filesystem scanning at tool invocation time. The
// definition only needs to change when the server binary, workspace
// folder registration, or configuration changes.
//
// The watchers are still useful: they log file events for debugging
// and DatabaseWatcher tracks known databases internally.
} catch (err) {
logger.warn(
`Failed to initialize file watchers: ${err instanceof Error ? err.message : String(err)}`,
Expand Down
30 changes: 29 additions & 1 deletion extensions/vscode/src/server/mcp-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export class McpProvider
*/
private readonly _extensionVersion: string;

/**
* Handle for the pending debounced `fireDidChange()` timer.
* Used to coalesce rapid file-system events into a single notification.
*/
private _debounceTimer: ReturnType<typeof globalThis.setTimeout> | undefined;

constructor(
private readonly serverManager: ServerManager,
private readonly envBuilder: EnvironmentBuilder,
Expand All @@ -43,6 +49,14 @@ export class McpProvider
this.push(this._onDidChange);
}

override dispose(): void {
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
this._debounceTimer = undefined;
}
super.dispose();
}

/**
* Soft notification: tell VS Code that definitions may have changed.
*
Expand All @@ -51,9 +65,18 @@ export class McpProvider
* will NOT restart the server. Use for lightweight updates (file watcher
* events, extension changes, background install completion) where the
* running server can continue with its current environment.
*
* Debounced: rapid-fire calls (e.g. from file-system watchers during
* a build) are coalesced into a single notification after a short delay.
*/
fireDidChange(): void {
this._onDidChange.fire();
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
}
this._debounceTimer = globalThis.setTimeout(() => {
this._debounceTimer = undefined;
this._onDidChange.fire();
}, 1_000);
}
Comment thread
data-douser marked this conversation as resolved.

/**
Expand All @@ -68,6 +91,11 @@ export class McpProvider
* Use for changes that require a server restart (configuration changes).
*/
requestRestart(): void {
// Cancel any pending debounced fireDidChange β€” the restart supersedes it.
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
this._debounceTimer = undefined;
}
this.envBuilder.invalidate();
this._revision++;
this.logger.info(
Expand Down
30 changes: 28 additions & 2 deletions extensions/vscode/src/server/pack-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ export class PackInstaller extends DisposableObject {
const actualCliVersion = this.cliResolver.getCliVersion();
const targetCliVersion = this.getTargetCliVersion();

if (actualCliVersion) {
this.logger.info(
`Detected CodeQL CLI version: ${actualCliVersion}, target: ${targetCliVersion}.`,
);
} else {
this.logger.info(
`CodeQL CLI version could not be determined. Target: ${targetCliVersion}. ` +
'Using bundled pack install.',
);
}

if (downloadEnabled && actualCliVersion && actualCliVersion !== targetCliVersion) {
this.logger.info(
`CodeQL CLI version ${actualCliVersion} differs from VSIX target ${targetCliVersion}. ` +
Expand All @@ -167,6 +178,10 @@ export class PackInstaller extends DisposableObject {
this.logger.info(
'Pack download did not succeed for all languages β€” falling back to bundled pack install.',
);
} else if (actualCliVersion && actualCliVersion === targetCliVersion) {
this.logger.info(
`CLI and target versions match (${actualCliVersion}). Using bundled pack install.`,
);
}

// Default path: install dependencies for bundled packs
Expand Down Expand Up @@ -199,20 +214,25 @@ export class PackInstaller extends DisposableObject {
);

let allSucceeded = true;
let successCount = 0;
for (const lang of languages) {
const packRef =
`${PackInstaller.PACK_SCOPE}/ql-mcp-${lang}-tools-src@${packVersion}`;
this.logger.info(`Downloading ${packRef}...`);
try {
await this.runCodeqlPackDownload(codeqlPath, packRef);
this.logger.info(`Downloaded ${packRef}.`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to download ${packRef}: ${err instanceof Error ? err.message : String(err)}`,
);
allSucceeded = false;
}
}
this.logger.info(
`Pack download complete: ${successCount}/${languages.length} languages succeeded.`,
);
return allSucceeded;
Comment thread
data-douser marked this conversation as resolved.
}

Expand All @@ -226,9 +246,11 @@ export class PackInstaller extends DisposableObject {
languages: string[],
): Promise<void> {
const qlRoot = this.getQlpackRoot();
let successCount = 0;

for (const lang of languages) {
const packDir = join(qlRoot, 'ql', lang, 'tools', 'src');
const packName = `${PackInstaller.PACK_SCOPE}/ql-mcp-${lang}-tools-src`;

// Check if the pack directory exists
try {
Expand All @@ -238,16 +260,20 @@ export class PackInstaller extends DisposableObject {
continue;
}

this.logger.info(`Installing CodeQL pack dependencies for ${lang}...`);
this.logger.info(`Installing CodeQL pack dependencies for ${packName} (${lang})...`);
try {
await this.runCodeqlPackInstall(codeqlPath, packDir);
this.logger.info(`Pack dependencies installed for ${lang}.`);
this.logger.info(`Pack dependencies installed for ${packName} (${lang}).`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to install pack dependencies for ${lang}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
this.logger.info(
`Bundled pack install complete: ${successCount}/${languages.length} languages succeeded.`,
);
}

/** Run `codeql pack install` for a single pack directory. */
Expand Down
3 changes: 1 addition & 2 deletions extensions/vscode/src/server/server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ export class ServerManager extends DisposableObject {
// VSIX bundle or monorepo server is present β€” no npm install required.
if (this.getBundledQlRoot()) {
this.logger.info(
`Using bundled server (v${this.getExtensionVersion()}). ` +
'No npm install required.',
`Bundled server ready (v${this.getExtensionVersion()}).`,
);
return false;
}
Expand Down
Loading
Loading