From 018812c8ee985259cc7fb96f934b8d9b12f28d76 Mon Sep 17 00:00:00 2001 From: George Tsiolis <120486+gtsiolis@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:05:29 +0000 Subject: [PATCH 1/3] feat: add --timeout flag to override the startup readiness deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lstk start (and the bare root) now accept --timeout to bound how long lstk waits for the emulator to report healthy. When set explicitly it overrides LSTK_STARTUP_TIMEOUT for a single run; --timeout 0 falls back to the per-mode default. restart and the snapshot auto-start path deliberately do not expose the flag. The underlying startup-timeout mechanism — per-mode defaults, the interactive keep-waiting/stop prompt, and startupMonitor — landed separately in #390; this change only exposes it as a CLI flag and adds an integration test covering the non-interactive timeout path. Generated with [Linear](https://linear.app/localstack/issue/PRO-357/lstk-start-should-time-out-on-failed-startup#agent-session-92655d31) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> Co-Authored-By: Claude --- CLAUDE.md | 2 +- cmd/root.go | 28 ++++++++++++++++++++++++++++ cmd/start.go | 4 ++++ test/integration/main_test.go | 34 ++++++++++++++++++++++++++++++++++ test/integration/start_test.go | 27 +++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26c7c3fb..3a39136b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,7 @@ This naming avoids AWS-specific "profile" terminology and uses a clear verb for Environment variables: - `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set) -- `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start. +- `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start. `lstk start --timeout ` (also on the bare root) overrides this for a single run; the flag wins over the env var when explicitly set, and `--timeout 0` falls back to the per-mode default (`addTimeoutFlag`/`applyTimeoutFlag` in `cmd/root.go`). `restart` and the snapshot auto-start path do not expose the flag. - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). - `LSTK_MERGE_STRATEGY` - Default merge strategy for `snapshot load` / `load` (`account-region-merge`, `overwrite`, or `service-merge`) when `--merge` is not passed; an explicit `--merge` always wins. Resolved in `resolveMergeStrategy` (`cmd/snapshot.go`). diff --git a/cmd/root.go b/cmd/root.go index d056bdd2..07651575 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,6 +83,9 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if err != nil { return err } + if err := applyTimeoutFlag(cmd, cfg); err != nil { + return err + } return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) }, } @@ -129,6 +132,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.Flags().Bool("persist", false, "Persist emulator state across restarts") addEmulatorTypeFlag(root) addSnapshotStartFlags(root) + addTimeoutFlag(root) // Parse lstk's global flags only when they precede the command name: with // interspersing disabled, Cobra consumes leading flags and hands everything @@ -402,6 +406,30 @@ func resolveEmulatorTypeFlag(cmd *cobra.Command) (config.EmulatorType, error) { return config.ParseEmulatorType(flagVal) } +// addTimeoutFlag registers the --timeout flag on a start-capable command. It is +// a per-run override of LSTK_STARTUP_TIMEOUT / the startup_timeout config; 0 +// (the default) leaves the env/config value in place, which in turn falls back +// to the per-mode default in resolveStartupTimeout. restart and the snapshot +// auto-start path deliberately do not expose this flag. +func addTimeoutFlag(cmd *cobra.Command) { + cmd.Flags().Duration("timeout", 0, "Maximum time to wait for the emulator to become ready (overrides LSTK_STARTUP_TIMEOUT; 0 uses the default)") +} + +// applyTimeoutFlag lets --timeout override the env/config-derived +// cfg.StartupTimeout, but only when the flag was explicitly set, so an unset +// flag preserves the LSTK_STARTUP_TIMEOUT value. +func applyTimeoutFlag(cmd *cobra.Command, cfg *env.Env) error { + if !cmd.Flags().Changed("timeout") { + return nil + } + timeout, err := cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + cfg.StartupTimeout = timeout + return nil +} + // walkCommandsWithRunE walks the Cobra command tree rooted at cmd, calling wrap // on every command that has a RunE so callers can layer cross-cutting behavior // (telemetry, JSON gating, tracing) onto it. diff --git a/cmd/start.go b/cmd/start.go index 8271eeb1..5f687e0f 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -46,11 +46,15 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai if err != nil { return err } + if err := applyTimeoutFlag(c, cfg); err != nil { + return err + } return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) }, } cmd.Flags().Bool("persist", false, "Persist emulator state across restarts") addEmulatorTypeFlag(cmd) addSnapshotStartFlags(cmd) + addTimeoutFlag(cmd) return cmd } diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 46ec3297..5fe0647e 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -234,6 +234,40 @@ func startExternalContainer(t *testing.T, ctx context.Context, imgName, name, ho }) } +// commitNeverHealthyImage builds a local-only image whose default command stays +// running (sleep infinity) but never serves /_localstack/health. Starting it via +// lstk exercises the failure path where the emulator comes up but never reports +// healthy. Returns the image reference; the image and its source container are +// removed on test cleanup. +func commitNeverHealthyImage(t *testing.T, ctx context.Context) string { + t.Helper() + + reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + + resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: &container.Config{Image: testImage}, + Name: "lstk-never-healthy-src", + }) + require.NoError(t, err, "failed to create source container") + t.Cleanup(func() { + _, _ = dockerClient.ContainerRemove(context.Background(), resp.ID, client.ContainerRemoveOptions{Force: true}) + }) + + const imageRef = "lstk-never-healthy:latest" + _, err = dockerClient.ContainerCommit(ctx, resp.ID, client.ContainerCommitOptions{ + Reference: imageRef, + Changes: []string{`CMD ["sleep", "infinity"]`}, + }) + require.NoError(t, err, "failed to commit never-healthy image") + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), imageRef, client.ImageRemoveOptions{Force: true}) + }) + return imageRef +} + func startTestSnowflakeContainer(t *testing.T, ctx context.Context) { t.Helper() startNamedTestContainer(t, ctx, snowflakeContainerName, "snowflake") diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 95987a67..8eea163f 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -1075,6 +1075,33 @@ image = "lstk-nonexistent-custom-image" assert.Contains(t, combined, "Failed to pull lstk-nonexistent-custom-image:latest") } +// TestStartTimesOutWhenEmulatorNeverBecomesHealthy verifies that --timeout bounds +// the health-check wait (PRO-357): a container that stays running but never serves +// /_localstack/health must fail fast with a clear error and a non-zero exit, +// instead of hanging indefinitely. +func TestStartTimesOutWhenEmulatorNeverBecomesHealthy(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + imageRef := commitNeverHealthyImage(t, ctx) + + home := t.TempDir() + configFile := filepath.Join(home, "config.toml") + require.NoError(t, os.WriteFile(configFile, + []byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\nport = \"4566\"\nimage = %q\n", imageRef)), 0644)) + + // The local image is reused (pull fails, ImageExists true), so the license + // pre-flight is skipped and a dummy token is enough to reach the health wait. + e := append(testEnvWithHome(home, ""), string(env.AuthToken)+"=fake-token") + stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start", "--timeout", "3s") + + require.Error(t, err, "expected start to fail when the emulator never becomes healthy") + requireExitCode(t, 1, err) + assert.Contains(t, stdout+stderr, "LocalStack did not become ready within 3s") +} + // TestStartFallsBackToLocalImageWhenPullFails verifies the offline degradation // path for image pulls: when the configured image cannot be pulled (registry // unreachable, or the image was never published) but is already present locally, From fbcbab673f94538990f3fb87c7a88cf5c434474f Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 23 Jul 2026 12:28:47 +0300 Subject: [PATCH 2/3] fix: pin the never-healthy test image tag so start reaches the health wait Co-Authored-By: Claude --- test/integration/main_test.go | 9 ++++++--- test/integration/start_test.go | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 5fe0647e..c356d1e2 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -237,8 +237,11 @@ func startExternalContainer(t *testing.T, ctx context.Context, imgName, name, ho // commitNeverHealthyImage builds a local-only image whose default command stays // running (sleep infinity) but never serves /_localstack/health. Starting it via // lstk exercises the failure path where the emulator comes up but never reports -// healthy. Returns the image reference; the image and its source container are -// removed on test cleanup. +// healthy. The tag must stay pinned (non-latest): a pinned locally-present image +// skips both the pull and the license checks, while a "latest" tag is validated +// post-pull via GetImageVersion, which fails on this image (no +// LOCALSTACK_BUILD_VERSION) before the health wait is ever reached. Returns the +// image reference; the image and its source container are removed on test cleanup. func commitNeverHealthyImage(t *testing.T, ctx context.Context) string { t.Helper() @@ -256,7 +259,7 @@ func commitNeverHealthyImage(t *testing.T, ctx context.Context) string { _, _ = dockerClient.ContainerRemove(context.Background(), resp.ID, client.ContainerRemoveOptions{Force: true}) }) - const imageRef = "lstk-never-healthy:latest" + const imageRef = "lstk-never-healthy:test" _, err = dockerClient.ContainerCommit(ctx, resp.ID, client.ContainerCommitOptions{ Reference: imageRef, Changes: []string{`CMD ["sleep", "infinity"]`}, diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 8eea163f..2416d1d0 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -1086,14 +1086,17 @@ func TestStartTimesOutWhenEmulatorNeverBecomesHealthy(t *testing.T) { ctx := testContext(t) imageRef := commitNeverHealthyImage(t, ctx) + imageName, imageTag, ok := strings.Cut(imageRef, ":") + require.True(t, ok, "never-healthy image reference must carry a pinned tag") home := t.TempDir() configFile := filepath.Join(home, "config.toml") require.NoError(t, os.WriteFile(configFile, - []byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\nport = \"4566\"\nimage = %q\n", imageRef)), 0644)) + []byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\nport = \"4566\"\nimage = %q\ntag = %q\n", imageName, imageTag)), 0644)) - // The local image is reused (pull fails, ImageExists true), so the license - // pre-flight is skipped and a dummy token is enough to reach the health wait. + // A pinned tag already present locally skips both the pull and the license + // checks (see commitNeverHealthyImage), so a dummy token is enough to reach + // the health wait. e := append(testEnvWithHome(home, ""), string(env.AuthToken)+"=fake-token") stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start", "--timeout", "3s") From 9939076a3a8e905a08894a3c76ab0e3370f55945 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 23 Jul 2026 12:37:23 +0300 Subject: [PATCH 3/3] fix: remove the leaked never-healthy container that held port 4566 in CI Co-Authored-By: Claude --- test/integration/start_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 2416d1d0..d38b2395 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -1089,6 +1089,16 @@ func TestStartTimesOutWhenEmulatorNeverBecomesHealthy(t *testing.T) { imageName, imageTag, ok := strings.Cut(imageRef, ":") require.True(t, ok, "never-healthy image reference must carry a pinned tag") + // A non-interactive startup timeout deliberately leaves the container running + // for inspection, and the pinned tag names it "localstack-aws-" — a name + // the shared cleanup() (which only removes "localstack-aws") never touches. + // Remove it explicitly, or it keeps holding port 4566 and breaks every later + // test that starts an emulator. Registered after commitNeverHealthyImage's + // cleanups so it runs before them (LIFO): the container goes first, then its image. + t.Cleanup(func() { + _, _ = dockerClient.ContainerRemove(context.Background(), "localstack-aws-"+imageTag, client.ContainerRemoveOptions{Force: true}) + }) + home := t.TempDir() configFile := filepath.Join(home, "config.toml") require.NoError(t, os.WriteFile(configFile,