diff --git a/src/plugins/manage.ts b/src/plugins/manage.ts index 9eca357..0f02549 100644 --- a/src/plugins/manage.ts +++ b/src/plugins/manage.ts @@ -1,4 +1,4 @@ -import { commands } from "vscode"; +import { commands, window } from "vscode"; import { createPlugin } from "../plugins.ts"; import { @@ -9,7 +9,7 @@ import { export default createPlugin( "manage", - ({ context, outputChannel, telemetry }) => { + ({ context, outputChannel, telemetry, localStackStatusTracker }) => { context.subscriptions.push( commands.registerCommand("localstack.viewLogs", () => { outputChannel.show(true); @@ -17,13 +17,27 @@ export default createPlugin( ); context.subscriptions.push( - commands.registerCommand("localstack.start", () => { - void startLocalStack(outputChannel, telemetry); + commands.registerCommand("localstack.start", async () => { + if (localStackStatusTracker.status() !== "stopped") { + window.showInformationMessage("LocalStack is already running."); + return; + } + localStackStatusTracker.forceContainerStatus("running"); + try { + await startLocalStack(outputChannel, telemetry); + } catch { + localStackStatusTracker.forceContainerStatus("stopped"); + } }), ); context.subscriptions.push( commands.registerCommand("localstack.stop", () => { + if (localStackStatusTracker.status() !== "running") { + window.showInformationMessage("LocalStack is not running."); + return; + } + localStackStatusTracker.forceContainerStatus("stopping"); void stopLocalStack(outputChannel, telemetry); }), ); diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 8075827..99b740c 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -8,6 +8,7 @@ import { CLI_PATHS } from "../constants.ts"; import { exec } from "./exec.ts"; import { spawn } from "./spawn.ts"; +import type { SpawnOptions } from "./spawn.ts"; const IMAGE_NAME = "localstack/localstack-pro"; const LOCALSTACK_LDM_PREVIEW = "1"; @@ -68,6 +69,7 @@ export const spawnLocalStack = async ( options: { outputChannel: LogOutputChannel; cancellationToken?: CancellationToken; + onStderr?: SpawnOptions["onStderr"]; }, ) => { const cli = await findLocalStack(); @@ -81,5 +83,6 @@ export const spawnLocalStack = async ( IMAGE_NAME, LOCALSTACK_LDM_PREVIEW, }, + onStderr: options.onStderr, }); }; diff --git a/src/utils/localstack-status.ts b/src/utils/localstack-status.ts index f9ac65b..f89525c 100644 --- a/src/utils/localstack-status.ts +++ b/src/utils/localstack-status.ts @@ -11,6 +11,7 @@ export type LocalStackStatus = "starting" | "running" | "stopping" | "stopped"; export interface LocalStackStatusTracker extends Disposable { status(): LocalStackStatus; + forceContainerStatus(status: ContainerStatus): void; onChange(callback: (status: LocalStackStatus) => void): void; } @@ -22,30 +23,35 @@ export async function createLocalStackStatusTracker( outputChannel: LogOutputChannel, timeTracker: TimeTracker, ): Promise { + let containerStatus: ContainerStatus | undefined; let status: LocalStackStatus | undefined; const emitter = createEmitter(outputChannel); let healthCheck: boolean | undefined; - const updateStatus = () => { - const newStatus = getLocalStackStatus( - containerStatusTracker.status(), - healthCheck, - ); + const setStatus = (newStatus: LocalStackStatus) => { if (status !== newStatus) { status = newStatus; void emitter.emit(status); } }; - containerStatusTracker.onChange(() => { - updateStatus(); + const deriveStatus = () => { + const newStatus = getLocalStackStatus(containerStatus, healthCheck); + setStatus(newStatus); + }; + + containerStatusTracker.onChange((newContainerStatus) => { + if (containerStatus !== newContainerStatus) { + containerStatus = newContainerStatus; + deriveStatus(); + } }); let healthCheckTimeout: NodeJS.Timeout | undefined; const startHealthCheck = async () => { healthCheck = await fetchHealth(); - updateStatus(); + deriveStatus(); healthCheckTimeout = setTimeout(() => void startHealthCheck(), 1_000); }; @@ -58,6 +64,12 @@ export async function createLocalStackStatusTracker( // biome-ignore lint/style/noNonNullAssertion: false positive return status!; }, + forceContainerStatus(newContainerStatus) { + if (containerStatus !== newContainerStatus) { + containerStatus = newContainerStatus; + deriveStatus(); + } + }, onChange(callback) { emitter.on(callback); if (status) { diff --git a/src/utils/manage.ts b/src/utils/manage.ts index 153159f..fb8cf1b 100644 --- a/src/utils/manage.ts +++ b/src/utils/manage.ts @@ -5,6 +5,7 @@ import { commands, env, Uri, window } from "vscode"; import { spawnLocalStack } from "./cli.ts"; import { exec } from "./exec.ts"; import { checkIsLicenseValid } from "./license.ts"; +import { spawn } from "./spawn.ts"; import type { Telemetry } from "./telemetry.ts"; export type LocalstackStatus = "running" | "starting" | "stopping" | "stopped"; @@ -85,7 +86,7 @@ export async function getLocalstackStatus(): Promise { export async function startLocalStack( outputChannel: LogOutputChannel, telemetry: Telemetry, -) { +): Promise { void showInformationMessage("Starting LocalStack.", { title: "View Logs", command: "localstack.viewLogs", @@ -105,6 +106,20 @@ export async function startLocalStack( ], { outputChannel, + onStderr(data: Buffer, context) { + const text = data.toString(); + // Currently, the LocalStack CLI does not exit if the container fails to start in specific scenarios. + // As a workaround, we look for a specific error message in the output to determine if the container failed to start. + if ( + text.includes( + "localstack.utils.container_utils.container_client.ContainerException", + ) + ) { + // Abort the process if we detect a ContainerException, otherwise it will hang indefinitely. + context.abort(); + throw new Error("ContainerException"); + } + }, }, ); @@ -129,6 +144,7 @@ export async function startLocalStack( title: "View Logs", command: "localstack.viewLogs", }); + throw error; } telemetry.track({ diff --git a/src/utils/spawn.ts b/src/utils/spawn.ts index 08bcafc..565eaf2 100644 --- a/src/utils/spawn.ts +++ b/src/utils/spawn.ts @@ -133,6 +133,14 @@ export class SpawnError extends Error { } } +export interface SpawnOptions { + outputLabel?: string; + outputChannel: LogOutputChannel; + cancellationToken?: CancellationToken; + environment?: Record | undefined; + onStderr?: (data: Buffer, context: { abort: () => void }) => void; +} + /** * Spawns a new process using the given `command`, with command-line arguments in `args`. * - All output is appended to the `options.outputChannel`, optionally prefixed by `options.outputLabel`. @@ -143,12 +151,7 @@ export class SpawnError extends Error { export const spawn = ( command: string, args: string[], - options: { - outputLabel?: string; - outputChannel: LogOutputChannel; - cancellationToken?: CancellationToken; - environment?: Record | undefined; - }, + options: SpawnOptions, ) => { return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( (resolve, reject) => { @@ -169,24 +172,38 @@ export const spawn = ( const child = childProcess.spawn(command, args, spawnOptions); + const killChild = () => { + // Use SIGINT on Unix, 'SIGTERM' on Windows + const isWindows = os.platform() === "win32"; + if (isWindows) { + child.kill("SIGTERM"); + } else { + child.kill("SIGINT"); + } + }; + const disposeCancel = options.cancellationToken?.onCancellationRequested( () => { outputChannel.appendLine( `${outputLabel}Command cancelled: ${commandLine}`, ); - // Use SIGINT on Unix, 'SIGTERM' on Windows - const isWindows = os.platform() === "win32"; - if (isWindows) { - child.kill("SIGTERM"); - } else { - child.kill("SIGINT"); - } + killChild(); reject(new Error("Command cancelled")); }, ); pipeToLogOutputChannel(child, outputChannel, outputLabel); + if (options.onStderr) { + child.stderr?.on("data", (data: Buffer) => + options.onStderr?.(data, { + abort() { + killChild(); + }, + }), + ); + } + child.on("close", (code, signal) => { disposeCancel?.dispose();