Skip to content

Commit 1f11a2d

Browse files
authored
feat: reflect LS status immediately after running start and stop commands (#26)
- Make status bar reflect "starting" or "stopping" immediately after running LocalStack start/stop commands. - Add heuristic to handle LocalStack CLI failures by inspecting output text.
1 parent b049377 commit 1f11a2d

File tree

5 files changed

+88
-26
lines changed

5 files changed

+88
-26
lines changed

src/plugins/manage.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { commands } from "vscode";
1+
import { commands, window } from "vscode";
22

33
import { createPlugin } from "../plugins.ts";
44
import {
@@ -9,21 +9,35 @@ import {
99

1010
export default createPlugin(
1111
"manage",
12-
({ context, outputChannel, telemetry }) => {
12+
({ context, outputChannel, telemetry, localStackStatusTracker }) => {
1313
context.subscriptions.push(
1414
commands.registerCommand("localstack.viewLogs", () => {
1515
outputChannel.show(true);
1616
}),
1717
);
1818

1919
context.subscriptions.push(
20-
commands.registerCommand("localstack.start", () => {
21-
void startLocalStack(outputChannel, telemetry);
20+
commands.registerCommand("localstack.start", async () => {
21+
if (localStackStatusTracker.status() !== "stopped") {
22+
window.showInformationMessage("LocalStack is already running.");
23+
return;
24+
}
25+
localStackStatusTracker.forceContainerStatus("running");
26+
try {
27+
await startLocalStack(outputChannel, telemetry);
28+
} catch {
29+
localStackStatusTracker.forceContainerStatus("stopped");
30+
}
2231
}),
2332
);
2433

2534
context.subscriptions.push(
2635
commands.registerCommand("localstack.stop", () => {
36+
if (localStackStatusTracker.status() !== "running") {
37+
window.showInformationMessage("LocalStack is not running.");
38+
return;
39+
}
40+
localStackStatusTracker.forceContainerStatus("stopping");
2741
void stopLocalStack(outputChannel, telemetry);
2842
}),
2943
);

src/utils/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CLI_PATHS } from "../constants.ts";
88

99
import { exec } from "./exec.ts";
1010
import { spawn } from "./spawn.ts";
11+
import type { SpawnOptions } from "./spawn.ts";
1112

1213
const IMAGE_NAME = "localstack/localstack-pro";
1314
const LOCALSTACK_LDM_PREVIEW = "1";
@@ -68,6 +69,7 @@ export const spawnLocalStack = async (
6869
options: {
6970
outputChannel: LogOutputChannel;
7071
cancellationToken?: CancellationToken;
72+
onStderr?: SpawnOptions["onStderr"];
7173
},
7274
) => {
7375
const cli = await findLocalStack();
@@ -81,5 +83,6 @@ export const spawnLocalStack = async (
8183
IMAGE_NAME,
8284
LOCALSTACK_LDM_PREVIEW,
8385
},
86+
onStderr: options.onStderr,
8487
});
8588
};

src/utils/localstack-status.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type LocalStackStatus = "starting" | "running" | "stopping" | "stopped";
1111

1212
export interface LocalStackStatusTracker extends Disposable {
1313
status(): LocalStackStatus;
14+
forceContainerStatus(status: ContainerStatus): void;
1415
onChange(callback: (status: LocalStackStatus) => void): void;
1516
}
1617

@@ -22,30 +23,35 @@ export async function createLocalStackStatusTracker(
2223
outputChannel: LogOutputChannel,
2324
timeTracker: TimeTracker,
2425
): Promise<LocalStackStatusTracker> {
26+
let containerStatus: ContainerStatus | undefined;
2527
let status: LocalStackStatus | undefined;
2628
const emitter = createEmitter<LocalStackStatus>(outputChannel);
2729

2830
let healthCheck: boolean | undefined;
2931

30-
const updateStatus = () => {
31-
const newStatus = getLocalStackStatus(
32-
containerStatusTracker.status(),
33-
healthCheck,
34-
);
32+
const setStatus = (newStatus: LocalStackStatus) => {
3533
if (status !== newStatus) {
3634
status = newStatus;
3735
void emitter.emit(status);
3836
}
3937
};
4038

41-
containerStatusTracker.onChange(() => {
42-
updateStatus();
39+
const deriveStatus = () => {
40+
const newStatus = getLocalStackStatus(containerStatus, healthCheck);
41+
setStatus(newStatus);
42+
};
43+
44+
containerStatusTracker.onChange((newContainerStatus) => {
45+
if (containerStatus !== newContainerStatus) {
46+
containerStatus = newContainerStatus;
47+
deriveStatus();
48+
}
4349
});
4450

4551
let healthCheckTimeout: NodeJS.Timeout | undefined;
4652
const startHealthCheck = async () => {
4753
healthCheck = await fetchHealth();
48-
updateStatus();
54+
deriveStatus();
4955
healthCheckTimeout = setTimeout(() => void startHealthCheck(), 1_000);
5056
};
5157

@@ -58,6 +64,12 @@ export async function createLocalStackStatusTracker(
5864
// biome-ignore lint/style/noNonNullAssertion: false positive
5965
return status!;
6066
},
67+
forceContainerStatus(newContainerStatus) {
68+
if (containerStatus !== newContainerStatus) {
69+
containerStatus = newContainerStatus;
70+
deriveStatus();
71+
}
72+
},
6173
onChange(callback) {
6274
emitter.on(callback);
6375
if (status) {

src/utils/manage.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { commands, env, Uri, window } from "vscode";
55
import { spawnLocalStack } from "./cli.ts";
66
import { exec } from "./exec.ts";
77
import { checkIsLicenseValid } from "./license.ts";
8+
import { spawn } from "./spawn.ts";
89
import type { Telemetry } from "./telemetry.ts";
910

1011
export type LocalstackStatus = "running" | "starting" | "stopping" | "stopped";
@@ -85,7 +86,7 @@ export async function getLocalstackStatus(): Promise<LocalstackStatus> {
8586
export async function startLocalStack(
8687
outputChannel: LogOutputChannel,
8788
telemetry: Telemetry,
88-
) {
89+
): Promise<void> {
8990
void showInformationMessage("Starting LocalStack.", {
9091
title: "View Logs",
9192
command: "localstack.viewLogs",
@@ -105,6 +106,20 @@ export async function startLocalStack(
105106
],
106107
{
107108
outputChannel,
109+
onStderr(data: Buffer, context) {
110+
const text = data.toString();
111+
// Currently, the LocalStack CLI does not exit if the container fails to start in specific scenarios.
112+
// As a workaround, we look for a specific error message in the output to determine if the container failed to start.
113+
if (
114+
text.includes(
115+
"localstack.utils.container_utils.container_client.ContainerException",
116+
)
117+
) {
118+
// Abort the process if we detect a ContainerException, otherwise it will hang indefinitely.
119+
context.abort();
120+
throw new Error("ContainerException");
121+
}
122+
},
108123
},
109124
);
110125

@@ -129,6 +144,7 @@ export async function startLocalStack(
129144
title: "View Logs",
130145
command: "localstack.viewLogs",
131146
});
147+
throw error;
132148
}
133149

134150
telemetry.track({

src/utils/spawn.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ export class SpawnError extends Error {
133133
}
134134
}
135135

136+
export interface SpawnOptions {
137+
outputLabel?: string;
138+
outputChannel: LogOutputChannel;
139+
cancellationToken?: CancellationToken;
140+
environment?: Record<string, string | undefined> | undefined;
141+
onStderr?: (data: Buffer, context: { abort: () => void }) => void;
142+
}
143+
136144
/**
137145
* Spawns a new process using the given `command`, with command-line arguments in `args`.
138146
* - All output is appended to the `options.outputChannel`, optionally prefixed by `options.outputLabel`.
@@ -143,12 +151,7 @@ export class SpawnError extends Error {
143151
export const spawn = (
144152
command: string,
145153
args: string[],
146-
options: {
147-
outputLabel?: string;
148-
outputChannel: LogOutputChannel;
149-
cancellationToken?: CancellationToken;
150-
environment?: Record<string, string | undefined> | undefined;
151-
},
154+
options: SpawnOptions,
152155
) => {
153156
return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
154157
(resolve, reject) => {
@@ -169,24 +172,38 @@ export const spawn = (
169172

170173
const child = childProcess.spawn(command, args, spawnOptions);
171174

175+
const killChild = () => {
176+
// Use SIGINT on Unix, 'SIGTERM' on Windows
177+
const isWindows = os.platform() === "win32";
178+
if (isWindows) {
179+
child.kill("SIGTERM");
180+
} else {
181+
child.kill("SIGINT");
182+
}
183+
};
184+
172185
const disposeCancel = options.cancellationToken?.onCancellationRequested(
173186
() => {
174187
outputChannel.appendLine(
175188
`${outputLabel}Command cancelled: ${commandLine}`,
176189
);
177-
// Use SIGINT on Unix, 'SIGTERM' on Windows
178-
const isWindows = os.platform() === "win32";
179-
if (isWindows) {
180-
child.kill("SIGTERM");
181-
} else {
182-
child.kill("SIGINT");
183-
}
190+
killChild();
184191
reject(new Error("Command cancelled"));
185192
},
186193
);
187194

188195
pipeToLogOutputChannel(child, outputChannel, outputLabel);
189196

197+
if (options.onStderr) {
198+
child.stderr?.on("data", (data: Buffer) =>
199+
options.onStderr?.(data, {
200+
abort() {
201+
killChild();
202+
},
203+
}),
204+
);
205+
}
206+
190207
child.on("close", (code, signal) => {
191208
disposeCancel?.dispose();
192209

0 commit comments

Comments
 (0)