Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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
22 changes: 18 additions & 4 deletions src/plugins/manage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { commands } from "vscode";
import { commands, window } from "vscode";

import { createPlugin } from "../plugins.ts";
import {
Expand All @@ -9,21 +9,35 @@ import {

export default createPlugin(
"manage",
({ context, outputChannel, telemetry }) => {
({ context, outputChannel, telemetry, localStackStatusTracker }) => {
context.subscriptions.push(
commands.registerCommand("localstack.viewLogs", () => {
outputChannel.show(true);
}),
);

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);
}),
);
Expand Down
3 changes: 3 additions & 0 deletions src/utils/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,6 +69,7 @@ export const spawnLocalStack = async (
options: {
outputChannel: LogOutputChannel;
cancellationToken?: CancellationToken;
onStderr?: SpawnOptions["onStderr"];
},
) => {
const cli = await findLocalStack();
Expand All @@ -81,5 +83,6 @@ export const spawnLocalStack = async (
IMAGE_NAME,
LOCALSTACK_LDM_PREVIEW,
},
onStderr: options.onStderr,
});
};
28 changes: 20 additions & 8 deletions src/utils/localstack-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -22,30 +23,35 @@ export async function createLocalStackStatusTracker(
outputChannel: LogOutputChannel,
timeTracker: TimeTracker,
): Promise<LocalStackStatusTracker> {
let containerStatus: ContainerStatus | undefined;
let status: LocalStackStatus | undefined;
const emitter = createEmitter<LocalStackStatus>(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);
};

Expand All @@ -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) {
Expand Down
18 changes: 17 additions & 1 deletion src/utils/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -85,7 +86,7 @@ export async function getLocalstackStatus(): Promise<LocalstackStatus> {
export async function startLocalStack(
outputChannel: LogOutputChannel,
telemetry: Telemetry,
) {
): Promise<void> {
void showInformationMessage("Starting LocalStack.", {
title: "View Logs",
command: "localstack.viewLogs",
Expand All @@ -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");
}
},
},
);

Expand All @@ -129,6 +144,7 @@ export async function startLocalStack(
title: "View Logs",
command: "localstack.viewLogs",
});
throw error;
}

telemetry.track({
Expand Down
43 changes: 30 additions & 13 deletions src/utils/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ export class SpawnError extends Error {
}
}

export interface SpawnOptions {
outputLabel?: string;
outputChannel: LogOutputChannel;
cancellationToken?: CancellationToken;
environment?: Record<string, string | undefined> | 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`.
Expand All @@ -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<string, string | undefined> | undefined;
},
options: SpawnOptions,
) => {
return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve, reject) => {
Expand All @@ -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();

Expand Down