diff --git a/packages/ansible-language-server/package.json b/packages/ansible-language-server/package.json index 0e1b067663..0226a6c24c 100644 --- a/packages/ansible-language-server/package.json +++ b/packages/ansible-language-server/package.json @@ -29,7 +29,6 @@ "handlebars": "^4.7.8", "ini": "^6.0.0", "lodash": "^4.17.23", - "uuid": "^13.0.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.12", diff --git a/packages/ansible-language-server/src/ansibleLanguageService.ts b/packages/ansible-language-server/src/ansibleLanguageService.ts index e6d4593a0d..79992a40ae 100644 --- a/packages/ansible-language-server/src/ansibleLanguageService.ts +++ b/packages/ansible-language-server/src/ansibleLanguageService.ts @@ -52,6 +52,7 @@ export class AnsibleLanguageService { public initialize(): void { this.initializeConnection(); this.registerLifecycleEventHandlers(); + this.registerShutdownHandler(); } private initializeConnection() { @@ -364,6 +365,16 @@ export class AnsibleLanguageService { ); } + private registerShutdownHandler() { + /* v8 ignore next 6 */ + this.connection.onShutdown(async () => { + // Dispose all persistent EE containers on language server shutdown + await this.workspaceManager.forEachContext(async (context) => { + await context.disposeExecutionEnvironment(); + }); + }); + } + private handleError(error: unknown, contextName: string) { const leadMessage = `An error occurred in '${contextName}' handler: `; if (error instanceof Error) { diff --git a/packages/ansible-language-server/src/services/executionEnvironment.ts b/packages/ansible-language-server/src/services/executionEnvironment.ts index b6767e8fef..b77e1181b4 100644 --- a/packages/ansible-language-server/src/services/executionEnvironment.ts +++ b/packages/ansible-language-server/src/services/executionEnvironment.ts @@ -1,9 +1,9 @@ import * as child_process from "child_process"; +import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; import { URI } from "vscode-uri"; import { Connection } from "vscode-languageserver"; -import { v4 as uuidv4 } from "uuid"; import { AnsibleConfig } from "@src/services/ansibleConfig.js"; import { ImagePuller } from "@src/utils/imagePuller.js"; import { asyncExec } from "@src/utils/misc.js"; @@ -14,6 +14,24 @@ import type { IVolumeMounts, } from "@src/interfaces/extensionSettings.js"; +/** + * Escape a string for safe use as a single shell argument. + * Wraps in single-quotes and escapes any embedded single-quotes. + */ +function shellQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} +/** Collect ANSIBLE_* env vars as shell-safe `-e KEY='VAL'` arguments. */ +function ansibleEnvArgs(): string[] { + const args: string[] = []; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("ANSIBLE_")) { + args.push("-e", `${key}=${shellQuote(value ?? "")}`); + } + } + args.push("-e", "ANSIBLE_FORCE_COLOR=0"); + return args; +} /* We are forced to ignore coverage because we can only measure it if we do it on all 3 platforms: linux, macos, wsl. Currently macos runners do not have podman/docker available. Once this is addressed please remove this coverage @@ -37,6 +55,16 @@ export class ExecutionEnvironment { private _container_volume_mounts: Array | undefined = undefined; + // Persistent container state + private _persistentContainerName: string | undefined = undefined; + private _isPersistentContainerRunning = false; + private _lastHealthCheckTime = 0; + private static readonly HEALTH_CHECK_INTERVAL_MS = 5000; + + // Command result cache for immutable queries (version checks, executable paths) + private _commandCache: Map = + new Map(); + constructor(connection: Connection, context: WorkspaceFolderContext) { this.connection = connection; this.context = context; @@ -78,6 +106,13 @@ export class ExecutionEnvironment { this.isServiceInitialized = false; return; } + + const containerStarted = this.startPersistentContainer(); + /* v8 ignore next 4 */ + if (!containerStarted) { + this.isServiceInitialized = false; + return; + } } catch (error) { /* v8 ignore next 3 */ if (error instanceof Error) { @@ -103,7 +138,18 @@ export class ExecutionEnvironment { ); return; } - const containerName = this._container_image.replace(/[^a-z0-9]/gi, "_"); + + this.ensurePersistentContainerHealthy(); + + if (!this._persistentContainerName || !this._isPersistentContainerRunning) { + this.connection.console.error( + "Persistent container is not running. Cannot fetch plugin docs.", + ); + return; + } + + const containerName = this._persistentContainerName; + const imageSafeName = this._container_image.replace(/[^a-z0-9]/gi, "_"); let progressTracker; try { @@ -116,15 +162,9 @@ export class ExecutionEnvironment { }) .trim(); const hostCacheBasePath = path.resolve( - `${process.env.HOME}/.cache/ansible-language-server/${containerName}/${this._container_image_id}`, + `${process.env.HOME}/.cache/ansible-language-server/${imageSafeName}/${this._container_image_id}`, ); - /* v8 ignore next 3 */ - const isContainerRunning = this.runContainer(containerName); - if (!isContainerRunning) { - return; - } - /* v8 ignore next 10 */ if (this.isPluginDocCacheValid(hostCacheBasePath)) { ansibleConfig.collections_paths = this.updateCachePaths( @@ -213,15 +253,16 @@ export class ExecutionEnvironment { if (progressTracker) { progressTracker.done(); } - /* v8 ignore next */ - this.cleanUpContainer(containerName); } } - public wrapContainerArgs( - command: string, - mountPaths?: Set, - ): string | undefined { + /** + * Execute a command inside the persistent background container via + * `docker exec` / `podman exec`. This avoids the overhead of provisioning + * a new container (namespace, overlayFS, veth pair) for every single + * diagnostic or linting command. + */ // cspell:ignore veth + public execInContainer(command: string): string | undefined { /* v8 ignore next 10 */ if ( !this.isServiceInitialized || @@ -233,84 +274,30 @@ export class ExecutionEnvironment { ); return undefined; } - /* v8 ignore next 92 */ - const workspaceFolderPath = URI.parse( - this.context.workspaceFolder.uri, - ).path; - const containerCommand: Array = [this._container_engine]; - containerCommand.push(...["run", "--rm"]); - containerCommand.push(...["--workdir", workspaceFolderPath]); - - containerCommand.push( - ...["-v", `${workspaceFolderPath}:${workspaceFolderPath}`], - ); - - for (const mountPath of mountPaths || []) { - // push to array only if mount path isn't an empty string, then let podman produce errors as needed - if (mountPath === "") { - continue; - } - - const volumeMountPath = `${mountPath}:${mountPath}`; - if (containerCommand.includes(volumeMountPath)) { - continue; - } - containerCommand.push("-v", volumeMountPath); - } - // handle container volume mounts setting from client - if (this.settingsVolumeMounts && this.settingsVolumeMounts.length > 0) { - this.settingsVolumeMounts.forEach((volumeMount) => { - if (containerCommand.includes(volumeMount)) { - return; - } - containerCommand.push("-v", volumeMount); - }); - } + // Ensure the persistent container is still alive (restarts if dead) + this.ensurePersistentContainerHealthy(); - // handle Ansible environment variables - for (const [envVarKey, envVarValue] of Object.entries(process.env)) { - if (envVarKey.startsWith("ANSIBLE_")) { - containerCommand.push("-e", `${envVarKey}=${envVarValue}`); - } + if (!this._isPersistentContainerRunning || !this._persistentContainerName) { + this.connection.console.error( + "Persistent container is not running. Cannot execute command.", + ); + return undefined; } - // ensure output is parsable (no ANSI) - containerCommand.push("-e", "ANSIBLE_FORCE_COLOR=0"); - if (this._container_engine === "podman") { - // container namespace stuff - containerCommand.push("--group-add=root"); - containerCommand.push("--ipc=host"); + const workspaceFolderPath = URI.parse( + this.context.workspaceFolder.uri, + ).path; + const containerCommand: Array = [this._container_engine]; + containerCommand.push("exec"); + containerCommand.push("--workdir", shellQuote(workspaceFolderPath)); - // docker does not support this option - containerCommand.push("--quiet"); - } else { - if (process.getuid) { - containerCommand.push(`--user=${process.getuid()}`); - } - } + containerCommand.push(...ansibleEnvArgs()); - // handle container options setting from client - if (this.settingsContainerOptions && this.settingsContainerOptions !== "") { - const containerOptions = this.settingsContainerOptions.split(" "); - containerOptions.forEach((containerOption) => { - if ( - containerOption === "" || - containerCommand.includes(containerOption) - ) { - return; - } - containerCommand.push(containerOption); - }); - } - containerCommand.push(`--name als_${uuidv4()}`); - containerCommand.push(this._container_image); + containerCommand.push(this._persistentContainerName); containerCommand.push(command); - const generatedCommand = containerCommand.join(" "); - this.connection.console.log( - `container engine invocation: ${generatedCommand}`, - ); - return generatedCommand; + + return containerCommand.join(" "); } private async pullContainerImage(): Promise { @@ -384,6 +371,270 @@ export class ExecutionEnvironment { return true; } + /** + * Generate a deterministic container name for the workspace folder. + * Uses a hash of the workspace folder URI so the name is stable across + * language server restarts, enabling reconnection to existing containers. + */ + private generatePersistentContainerName(): string { + const workspaceUri = this.context.workspaceFolder.uri; + const hash = crypto + .createHash("sha256") + .update(workspaceUri) + .digest("hex") + .substring(0, 12); + return `als_persistent_${hash}`; + } + + /** + * Start a single persistent background container that will be reused for + * all command execution via `docker exec` / `podman exec`. + */ + private startPersistentContainer(): boolean { + /* v8 ignore next 5 */ + if (!this._container_engine || !this._container_image) { + this.connection.console.error( + "Cannot start persistent container: engine or image not set.", + ); + return false; + } + + this._persistentContainerName = this.generatePersistentContainerName(); + + // Check if a container with this name already exists (e.g., from a + // previous language server session) and reuse it if healthy. + if (this.doesContainerNameExist(this._persistentContainerName)) { + if (this.isContainerRunning(this._persistentContainerName)) { + // Verify the running container uses the configured image + if ( + this.getContainerImage(this._persistentContainerName) === + this._container_image + ) { + this.connection.console.log( + `Reusing existing persistent container '${this._persistentContainerName}'`, + ); + this._isPersistentContainerRunning = true; + this._lastHealthCheckTime = Date.now(); + return true; + } + this.connection.console.log( + `Persistent container image mismatch — recreating with '${this._container_image}'`, + ); + } + // Container exists but is not running or uses wrong image — remove it + this.cleanUpContainer(this._persistentContainerName); + } + + try { + const workspaceFolderPath = URI.parse( + this.context.workspaceFolder.uri, + ).path; + + // Build the docker/podman run command for a long-lived background container. + // Do not add '-t' option as it causes stderr noise about TTY. + const containerCommand: Array = [this._container_engine]; + containerCommand.push("run", "--rm", "-d"); + containerCommand.push("--workdir", shellQuote(workspaceFolderPath)); + + // Mount workspace folder + containerCommand.push( + "-v", + `${shellQuote(workspaceFolderPath)}:${shellQuote(workspaceFolderPath)}`, + ); + + // Mount configured volume mounts + if (this.settingsVolumeMounts && this.settingsVolumeMounts.length > 0) { + containerCommand.push(...this.settingsVolumeMounts); + } + + containerCommand.push(...ansibleEnvArgs()); + + if (this._container_engine === "podman") { + containerCommand.push("--group-add=root"); + containerCommand.push("--ipc=host"); + containerCommand.push("--quiet"); + } else { + if (process.getuid) { + containerCommand.push(`--user=${process.getuid()}`); + } + } + + // Handle container options setting from client + if ( + this.settingsContainerOptions && + this.settingsContainerOptions !== "" + ) { + const containerOptions = this.settingsContainerOptions.split(" "); + containerOptions.forEach((containerOption) => { + if ( + containerOption === "" || + containerCommand.includes(containerOption) + ) { + return; + } + containerCommand.push(containerOption); + }); + } + + containerCommand.push("--name", this._persistentContainerName); + containerCommand.push(this._container_image); + // Keep the container alive indefinitely; docker exec is used for all commands. + containerCommand.push("sleep", "infinity"); + + const command = containerCommand.join(" "); + this.connection.console.log(`Starting persistent container: ${command}`); + child_process.execSync(command, { encoding: "utf-8" }); + + // Verify the container is responsive + const healthCheck = child_process.spawnSync( + this._container_engine, + ["exec", this._persistentContainerName, "echo", "ok"], + { encoding: "utf-8", timeout: 10000, shell: false }, + ); + if (healthCheck.status !== 0) { + this.connection.console.error( + `Persistent container health check failed: ${healthCheck.stderr}`, + ); + this.cleanUpContainer(this._persistentContainerName); + return false; + } + + this._isPersistentContainerRunning = true; + this._lastHealthCheckTime = Date.now(); + this.connection.console.log( + `Persistent container '${this._persistentContainerName}' started and healthy.`, + ); + return true; + } catch (error) { + this.connection.window.showErrorMessage( + `Failed to start persistent execution environment container '${this._container_image}': ${error}`, + ); + this._isPersistentContainerRunning = false; + return false; + } + } + + /** + * Check if a named container is currently running (not just existing). + */ + private isContainerRunning(containerName: string): boolean { + if (!this._container_engine) { + return false; + } + try { + const result = child_process.spawnSync( + this._container_engine, + ["ps", "-q", "--filter", `name=^${containerName}$`], + { encoding: "utf-8", shell: false }, + ); + return result.stdout.trim() !== ""; + } catch { + return false; + } + } + + /** + * Get the image used by an existing container. + * Returns empty string on failure. + */ + private getContainerImage(containerName: string): string { + if (!this._container_engine) { + return ""; + } + try { + const result = child_process.spawnSync( + this._container_engine, + ["inspect", "--format", "{{.Config.Image}}", containerName], + { encoding: "utf-8", shell: false }, + ); + if (result.status !== 0) { + return ""; + } + return result.stdout.trim(); + } catch { + return ""; + } + } + + /** + * Ensure the persistent container is still running. If it has died + * (e.g., OOM kill, manual docker kill), restart it automatically. + * The health check result is cached for HEALTH_CHECK_INTERVAL_MS to + * avoid per-command overhead. + */ + private ensurePersistentContainerHealthy(): void { + if (!this._persistentContainerName || !this._container_engine) { + return; + } + + const now = Date.now(); + if ( + this._isPersistentContainerRunning && + now - this._lastHealthCheckTime < + ExecutionEnvironment.HEALTH_CHECK_INTERVAL_MS + ) { + return; // Recent health check was OK, skip + } + + const running = this.isContainerRunning(this._persistentContainerName); + this._lastHealthCheckTime = now; + + if (running) { + this._isPersistentContainerRunning = true; + return; + } + + // Container is dead — attempt restart + this.connection.console.info( + `Persistent container '${this._persistentContainerName}' is not running. Restarting...`, + ); + this._isPersistentContainerRunning = false; + this._commandCache.clear(); + this.startPersistentContainer(); + } + + /** + * Stop and remove the persistent container. Called on language server + * shutdown, configuration change, or workspace folder removal. + */ + public dispose(): void { + this._commandCache.clear(); + if (this._persistentContainerName) { + this.connection.console.log( + `Disposing persistent container '${this._persistentContainerName}'`, + ); + this.cleanUpContainer(this._persistentContainerName); + this._isPersistentContainerRunning = false; + this._persistentContainerName = undefined; + } + } + + /** + * Get a cached command result, or undefined if not cached. + */ + public getCachedCommand( + cacheKey: string, + ): { stdout: string; stderr: string } | undefined { + return this._commandCache.get(cacheKey); + } + + /** + * Cache the result of a command execution. + */ + public setCachedCommand( + cacheKey: string, + result: { stdout: string; stderr: string }, + ): void { + this._commandCache.set(cacheKey, result); + } + + /** + * Clear the command result cache (e.g., on configuration change). + */ + public clearCommandCache(): void { + this._commandCache.clear(); + } + private cleanUpContainer(containerName: string): void { /* v8 ignore next 75 */ if (!this._container_engine) { @@ -400,7 +651,7 @@ export class ExecutionEnvironment { try { const result = child_process.spawnSync( this._container_engine, - ["ps", "-q", "--filter", `name=${containerName}`], + ["ps", "-q", "--filter", `name=^${containerName}$`], { encoding: "utf-8", shell: false }, ); runningContainers = result.stdout.trim(); @@ -433,7 +684,7 @@ export class ExecutionEnvironment { try { const result = child_process.spawnSync( this._container_engine, - ["container", "ls", "-aq", "-f", `name=${containerName}`], + ["container", "ls", "-aq", "-f", `name=^${containerName}$`], { encoding: "utf-8", shell: false }, ); allContainers = result.stdout.trim(); @@ -472,7 +723,7 @@ export class ExecutionEnvironment { try { const result = child_process.spawnSync( this._container_engine, - ["container", "ls", "-aq", "-f", `name=${containerName}`], + ["container", "ls", "-aq", "-f", `name=^${containerName}$`], { encoding: "utf-8", shell: false }, ); containerNameExist = result.stdout.trim() !== ""; @@ -541,47 +792,6 @@ export class ExecutionEnvironment { } } - private runContainer(containerName: string): boolean { - /* v8 ignore next 38 */ - // ensure container is not running - this.cleanUpContainer(containerName); - - try { - // Do not add '-t' option when running the containers as this causes stderr noise, such: - // The input device is not a TTY. The --tty and--interactive flags might not work properly - let command = `${this._container_engine} run -i --rm -d `; - if (this.settingsVolumeMounts && this.settingsVolumeMounts.length > 0) { - command += this.settingsVolumeMounts.join(" "); - } - - // handle Ansible environment variables - for (const [envVarKey, envVarValue] of Object.entries(process.env)) { - if (envVarKey.startsWith("ANSIBLE_")) { - command += ` -e ${envVarKey}=${envVarValue} `; - } - } - command += ` -e ANSIBLE_FORCE_COLOR=0 `; // ensure output is parsable (no ANSI) - if ( - this.settingsContainerOptions && - this.settingsContainerOptions !== "" - ) { - command += ` ${this.settingsContainerOptions} `; - } - command += ` --name ${containerName} ${this._container_image} bash`; - - this.connection.console.log(`run container with command '${command}'`); - child_process.execSync(command, { - encoding: "utf-8", - }); - } catch (error) { - this.connection.window.showErrorMessage( - `Failed to initialize execution environment '${this._container_image}': ${error}`, - ); - return false; - } - return true; - } - private async copyPluginDocFiles( hostPluginDocCacheBasePath: string, containerName: string, @@ -591,8 +801,11 @@ export class ExecutionEnvironment { /* v8 ignore next 33 */ const updatedHostDocPath: string[] = []; - containerPluginPaths.forEach((srcPath) => { - const destPath = path.join(hostPluginDocCacheBasePath, srcPath); + for (const srcPath of containerPluginPaths) { + // Strip leading separators to prevent path.join from producing + // an absolute path that escapes the cache directory. + const safeSrcPath = srcPath.replace(/^[/\\]+/, ""); + const destPath = path.join(hostPluginDocCacheBasePath, safeSrcPath); if (fs.existsSync(destPath)) { updatedHostDocPath.push(destPath); } else { @@ -600,24 +813,24 @@ export class ExecutionEnvironment { srcPath === "" || !this.isPluginInPath(containerName, srcPath, searchKind) ) { - return; + continue; } const destPathFolder = destPath .split(path.sep) .slice(0, -1) .join(path.sep); fs.mkdirSync(destPath, { recursive: true }); - const copyCommand = `${this._container_engine} cp ${containerName}:${srcPath} ${destPathFolder}`; + const copyCommand = `${this._container_engine} cp ${shellQuote(`${containerName}:${srcPath}`)} ${shellQuote(destPathFolder)}`; this.connection.console.log( `Copying plugins from container to local cache path ${copyCommand}`, ); - asyncExec(copyCommand, { + await asyncExec(copyCommand, { encoding: "utf-8", }); updatedHostDocPath.push(destPath); } - }); + } return updatedHostDocPath; } @@ -629,7 +842,8 @@ export class ExecutionEnvironment { ): string[] { const localCachePaths: string[] = []; pluginPaths.forEach((srcPath) => { - const destPath = path.join(cacheBasePath, srcPath); + const safeSrcPath = srcPath.replace(/^[/\\]+/, ""); + const destPath = path.join(cacheBasePath, safeSrcPath); if (fs.existsSync(destPath)) { localCachePaths.push(destPath); } diff --git a/packages/ansible-language-server/src/services/workspaceManager.ts b/packages/ansible-language-server/src/services/workspaceManager.ts index 3b9430dbbf..b97bfe7304 100644 --- a/packages/ansible-language-server/src/services/workspaceManager.ts +++ b/packages/ansible-language-server/src/services/workspaceManager.ts @@ -103,8 +103,13 @@ export class WorkspaceManager { public handleWorkspaceChanged(event: WorkspaceFoldersChangeEvent): void { const removedUris = new Set(event.removed.map((folder) => folder.uri)); - // We only keep contexts of existing workspace folders + // Dispose persistent containers for removed workspace folders for (const removedUri of removedUris) { + const context = this.folderContexts.get(removedUri); + /* v8 ignore next 3 */ + if (context) { + void context.disposeExecutionEnvironment(); + } this.folderContexts.delete(removedUri); } @@ -159,7 +164,9 @@ export class WorkspaceFolderContext { this.workspaceFolder.uri, () => { // in case the configuration changes for this folder, we should - // invalidate the services that rely on it in initialization + // invalidate the services that rely on it in initialization. + // The new EE's startPersistentContainer() handles cleanup of any + // existing container with the same deterministic name. this._executionEnvironment = undefined; this._ansibleConfig = undefined; this._docsLibrary = undefined; @@ -175,7 +182,9 @@ export class WorkspaceFolderContext { for (const fileEvent of params.changes) { if (fileEvent.uri.startsWith(this.workspaceFolder.uri)) { // in case the configuration changes for this folder, we should - // invalidate the services that rely on it in initialization + // invalidate the services that rely on it in initialization. + // The new EE's startPersistentContainer() handles cleanup of any + // existing container with the same deterministic name. this._executionEnvironment = undefined; this._ansibleConfig = undefined; this._docsLibrary = undefined; @@ -216,12 +225,34 @@ export class WorkspaceFolderContext { } public clearCachedServices(): void { + // Note: we don't dispose the EE here to avoid a race condition where the + // async dispose kills a container that a new EE has just started (same + // deterministic name). The new EE's startPersistentContainer() handles + // cleanup of any existing container with the same name. this._executionEnvironment = undefined; this._ansibleConfig = undefined; this._docsLibrary = undefined; this._ansibleInventory = undefined; } + /** + * Dispose the execution environment's persistent container and clear its + * reference so a new one is created on next access. + */ + public async disposeExecutionEnvironment(): Promise { + /* v8 ignore next 10 */ + if (this._executionEnvironment) { + const eeThenable = this._executionEnvironment; + this._executionEnvironment = undefined; + try { + const ee = await Promise.resolve(eeThenable); + ee.dispose(); + } catch { + // EE failed to initialize — nothing to dispose + } + } + } + public get ansibleLint(): AnsibleLint { if (!this._ansibleLint) { this._ansibleLint = new AnsibleLint(this.connection, this); diff --git a/packages/ansible-language-server/src/utils/commandRunner.ts b/packages/ansible-language-server/src/utils/commandRunner.ts index 4c22b52d87..e48cc7b8f2 100644 --- a/packages/ansible-language-server/src/utils/commandRunner.ts +++ b/packages/ansible-language-server/src/utils/commandRunner.ts @@ -62,12 +62,23 @@ export class CommandRunner { command = result.command; runEnv = result.env; } else { + /* v8 ignore next 14 -- EE path requires Docker, not available in unit tests */ + // Warn about paths that are not covered by the persistent container's mounts + if (mountPaths && this.connection) { + const workspacePath = URI.parse(this.context.workspaceFolder.uri).path; + for (const mp of mountPaths) { + if (!mp.startsWith(workspacePath)) { + this.connection.console.warn( + `[EE] Mount path '${mp}' is outside the workspace folder and may not be accessible inside the persistent container. ` + + `Configure additional volume mounts in the Execution Environment settings.`, + ); + } + } + } + // prepare command and env for execution environment run const executionEnvironment = await this.context.executionEnvironment; - command = executionEnvironment.wrapContainerArgs( - `${executable} ${args}`, - mountPaths, - ); + command = executionEnvironment.execInContainer(`${executable} ${args}`); runEnv = process.env; } if (command === undefined) { diff --git a/packages/ansible-language-server/test/helper.ts b/packages/ansible-language-server/test/helper.ts index 73a3577a12..7acb6f0027 100644 --- a/packages/ansible-language-server/test/helper.ts +++ b/packages/ansible-language-server/test/helper.ts @@ -85,6 +85,9 @@ export async function disableExecutionEnvironmentSettings( (await docSettings).executionEnvironment.enabled = false; (await docSettings).executionEnvironment.volumeMounts = []; if (context) { + // Dispose the persistent EE container before clearing cached services, + // since clearCachedServices() intentionally skips disposal. + await context.disposeExecutionEnvironment(); context.clearCachedServices(); } } diff --git a/packages/ansible-language-server/test/services/executionEnvironment.test.ts b/packages/ansible-language-server/test/services/executionEnvironment.test.ts index c820626060..ca81994402 100644 --- a/packages/ansible-language-server/test/services/executionEnvironment.test.ts +++ b/packages/ansible-language-server/test/services/executionEnvironment.test.ts @@ -100,6 +100,19 @@ describe("@ee", () => { expect(ee.isServiceInitialized).toBe(false); }); + it("should not initialize if startPersistentContainer fails", async () => { + mockContext.documentSettings.get.resolves(mockSettings); + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + sandbox.stub(ee as any, "setContainerEngine").returns(true); + sandbox.stub(ee as any, "pullContainerImage").resolves(true); + sandbox.stub(ee as any, "startPersistentContainer").returns(false); + await ee.initialize(); + expect(ee.isServiceInitialized).toBe(false); + }); + it("should set isServiceInitialized true if all steps succeed", async () => { mockContext.documentSettings.get.resolves(mockSettings); const ee = new ExecutionEnvironment( @@ -108,6 +121,7 @@ describe("@ee", () => { ); sandbox.stub(ee as any, "setContainerEngine").returns(true); sandbox.stub(ee as any, "pullContainerImage").resolves(true); + sandbox.stub(ee as any, "startPersistentContainer").returns(true); await ee.initialize(); expect(ee.isServiceInitialized).toBe(true); }); @@ -124,17 +138,35 @@ describe("@ee", () => { }); }); - describe("wrapContainerArgs", () => { + describe("execInContainer", () => { it("should return undefined if not initialized", () => { const ee = new ExecutionEnvironment( mockConnection as any, mockContext as any, ); - const result = ee.wrapContainerArgs("echo hello"); + const result = ee.execInContainer("echo hello"); expect(result).toBeUndefined(); }); - it("should generate a container command string", () => { + it("should generate a docker exec command when persistent container is running", () => { + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + ee.isServiceInitialized = true; + (ee as any)._container_engine = "docker"; + (ee as any)._container_image = "test-image"; + (ee as any)._persistentContainerName = "als_persistent_abc123"; + (ee as any)._isPersistentContainerRunning = true; + (ee as any)._lastHealthCheckTime = Date.now(); + const result = ee.execInContainer("ansible-lint --version"); + expect(result).toContain("docker exec"); + expect(result).toContain("als_persistent_abc123"); + expect(result).toContain("ansible-lint --version"); + expect(result).not.toContain("docker run"); + }); + + it("should return undefined when persistent container is not running", () => { const ee = new ExecutionEnvironment( mockConnection as any, mockContext as any, @@ -142,12 +174,75 @@ describe("@ee", () => { ee.isServiceInitialized = true; (ee as any)._container_engine = "docker"; (ee as any)._container_image = "test-image"; - (ee as any).settingsVolumeMounts = []; - (ee as any).settingsContainerOptions = ""; - const result = ee.wrapContainerArgs("echo hello", new Set(["/tmp"])); - expect(result).toContain("docker run --rm"); - expect(result).toContain("test-image"); - expect(result).toContain("echo hello"); + (ee as any)._persistentContainerName = "als_persistent_abc123"; + (ee as any)._isPersistentContainerRunning = false; + (ee as any)._lastHealthCheckTime = 0; + sandbox + .stub(ee as any, "ensurePersistentContainerHealthy") + .callsFake(() => { + (ee as any)._isPersistentContainerRunning = false; + }); + const result = ee.execInContainer("echo hello"); + expect(result).toBeUndefined(); + }); + }); + + describe("dispose", () => { + it("should clean up persistent container", () => { + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + (ee as any)._container_engine = "docker"; + (ee as any)._persistentContainerName = "als_persistent_abc123"; + (ee as any)._isPersistentContainerRunning = true; + const cleanUpStub = sandbox.stub(ee as any, "cleanUpContainer"); + ee.dispose(); + expect(cleanUpStub.calledWith("als_persistent_abc123")).toBe(true); + expect((ee as any)._isPersistentContainerRunning).toBe(false); + expect((ee as any)._persistentContainerName).toBeUndefined(); + }); + + it("should be safe to call when no persistent container exists", () => { + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + expect(() => ee.dispose()).not.toThrow(); + }); + }); + + describe("command cache", () => { + it("should store and retrieve cached commands", () => { + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + ee.setCachedCommand("python3 --version", { + stdout: "Python 3.11.0", + stderr: "", + }); + const cached = ee.getCachedCommand("python3 --version"); + expect(cached).toBeDefined(); + expect(cached?.stdout).toBe("Python 3.11.0"); + }); + + it("should return undefined for uncached commands", () => { + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + expect(ee.getCachedCommand("nonexistent")).toBeUndefined(); + }); + + it("should clear the cache", () => { + const ee = new ExecutionEnvironment( + mockConnection as any, + mockContext as any, + ); + ee.setCachedCommand("key", { stdout: "val", stderr: "" }); + ee.clearCommandCache(); + expect(ee.getCachedCommand("key")).toBeUndefined(); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a194fbed9..bacfccb52c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,9 +260,6 @@ importers: lodash: specifier: ^4.17.23 version: 4.18.1 - uuid: - specifier: ^13.0.0 - version: 13.0.0 vscode-languageserver: specifier: ^9.0.1 version: 9.0.1