Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ Scope: the first release **runs** extensions (PATH and bundled-dir resolution) a
See [extensions-authoring.md](docs/extensions-authoring.md) for the author-facing contract.


# Signal Forwarding to Wrapped Tools

Wrapped external tools (`aws`, `terraform`, `cdk`, `sam`, `az`, and extensions) are run through `proc.Run(cmd)` (in `internal/proc/run.go`) rather than `cmd.Run()`. These execs are created with `exec.CommandContext` using lstk's root context, which is cancelled on `SIGINT`/`SIGTERM`; `exec.CommandContext`'s default `Cancel` would then SIGKILL the child immediately, denying tools like `terraform apply` the chance to clean up (e.g. release the state lock). `proc.Run` disarms that (its `Cancel` returns `os.ErrProcessDone`, which both suppresses the kill and avoids injecting `context.Canceled` into the wait result, preserving the tool's real exit code) and instead lets the tool terminate from the signal it receives, waiting for it to finish its own shutdown. Forwarding is per-signal: `SIGTERM` is always relayed to the child (a terminal never generates it, so `kill <lstk-pid>` / `timeout` / an IDE stop button would otherwise never reach the tool), while `SIGINT` is relayed only when none of lstk's std streams is a terminal — an attached terminal already delivers Ctrl-C to the child via the foreground process group, and a second near-simultaneous SIGINT makes tools like terraform abort immediately instead of cleaning up. The any-stream check matters: with only stdin redirected (`yes | lstk terraform apply`) lstk still sits in the terminal's foreground process group. This differs from `npm/launcher.js`, which forwards unconditionally — safe there because its child is lstk itself, which tolerates duplicate signals; wrapped tools do not. Short internal captured-output execs (version checks, schema discovery, backend provisioning) still use `cmd.Run()` directly. End-to-end signal tests live in `test/integration/signal_forwarding_test.go`, backed by the reference extension's `signal-wait` mode.


# Snapshots

`lstk snapshot` captures and restores the running emulator's state. For Snowflake and Azure, snapshot support is still maturing, so these commands surface a friendly heads-up that results may be incomplete. Domain logic lives in `internal/snapshot/`; `cmd/snapshot.go` is wiring + output-mode selection.
Expand Down
3 changes: 2 additions & 1 deletion internal/awscli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/localstack/lstk/internal/awsconfig"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/proc"
)

const InstallURL = "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
Expand Down Expand Up @@ -62,7 +63,7 @@ func Exec(ctx context.Context, endpointURL string, useProfile bool, stdout, stde
cmd.Env = BuildEnv(os.Environ())
}

if err := cmd.Run(); err != nil {
if err := proc.Run(cmd); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
span.SetAttributes(attribute.Int("aws.exit_code", exitErr.ExitCode()))
Expand Down
4 changes: 3 additions & 1 deletion internal/azurecli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"

"github.com/localstack/lstk/internal/proc"
)

const InstallURL = "https://learn.microsoft.com/en-us/cli/azure/"
Expand Down Expand Up @@ -51,7 +53,7 @@ func Exec(ctx context.Context, extraEnv []string, stdin io.Reader, stdout, stder
if len(extraEnv) > 0 {
cmd.Env = append(os.Environ(), extraEnv...)
}
if err := cmd.Run(); err != nil {
if err := proc.Run(cmd); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
span.SetAttributes(attribute.Int("az.exit_code", exitErr.ExitCode()))
Expand Down
3 changes: 2 additions & 1 deletion internal/extension/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"go.opentelemetry.io/otel/codes"

"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/proc"
)

// Invoke executes the resolved extension with args forwarded unmodified,
Expand Down Expand Up @@ -51,7 +52,7 @@ func Invoke(ctx context.Context, ext *Extension, args []string, runCtx Context)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
if err := proc.Run(cmd); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
span.SetAttributes(attribute.Int("extension.exit_code", exitErr.ExitCode()))
Expand Down
3 changes: 2 additions & 1 deletion internal/iac/cdk/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/proc"
)

// Run proxies an AWS CDK invocation against LocalStack. It locates the cdk
Expand Down Expand Up @@ -75,7 +76,7 @@ func Run(ctx context.Context, endpointURL, region string, sink output.Sink, logg
cmd.Stderr = os.Stderr
cmd.Env = BuildEnv(os.Environ(), effectiveEndpoint, s3Endpoint, region)

if err := cmd.Run(); err != nil {
if err := proc.Run(cmd); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
span.SetAttributes(attribute.Int("cdk.exit_code", exitErr.ExitCode()))
Expand Down
3 changes: 2 additions & 1 deletion internal/iac/sam/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/proc"
)

const installDocsURL = "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html"
Expand Down Expand Up @@ -69,7 +70,7 @@ func Run(ctx context.Context, endpointURL, account, region string, sink output.S
cmd.Stderr = os.Stderr
cmd.Env = BuildEnv(os.Environ(), effectiveEndpoint, account, region)

if err := cmd.Run(); err != nil {
if err := proc.Run(cmd); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
span.SetAttributes(attribute.Int("sam.exit_code", exitErr.ExitCode()))
Expand Down
3 changes: 2 additions & 1 deletion internal/iac/terraform/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/proc"
)

// Run proxies a terraform invocation against LocalStack. The path taken depends
Expand Down Expand Up @@ -229,7 +230,7 @@ func runTerraform(ctx context.Context, span trace.Span, tfBin string, args []str
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
if err := proc.Run(cmd); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
span.SetAttributes(attribute.Int("terraform.exit_code", exitErr.ExitCode()))
Expand Down
95 changes: 95 additions & 0 deletions internal/proc/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Package proc runs wrapped external tools (aws, terraform, cdk, sam, az, and
// extensions) so that termination signals reach the child gracefully instead of
// hard-killing it.
package proc

import (
"os"
"os/exec"
"os/signal"
"syscall"

"github.com/localstack/lstk/internal/terminal"
)

// forwardedSignals are relayed to the wrapped child so it can shut down
// gracefully (e.g. terraform releasing its state lock) rather than being killed
// abruptly.
var forwardedSignals = []os.Signal{os.Interrupt, syscall.SIGTERM}

// shouldForward decides whether a received signal is relayed to the child.
// SIGTERM is never generated by a terminal, so it is always forwarded — without
// that, `kill <lstk-pid>` or `timeout 30 lstk terraform apply` would never reach
// the wrapped tool. SIGINT is terminal-generated (Ctrl-C) and already delivered
// to the child via the controlling terminal's foreground process group, so
// forwarding it while attached to a terminal would double-signal the tool — and
// a second, near-simultaneous SIGINT makes tools like terraform abort
// immediately instead of cleaning up.
func shouldForward(sig os.Signal, attachedToTerminal bool) bool {
return sig != os.Interrupt || !attachedToTerminal
}

// attachedToTerminal reports whether any of lstk's std streams is a terminal —
// i.e. whether a terminal's foreground process group will deliver Ctrl-C to the
// wrapped child directly. Checking stdin alone is not enough: with stdin
// redirected (`yes | lstk terraform apply`) lstk still runs in the terminal's
// foreground process group.
func attachedToTerminal() bool {
return terminal.IsTerminal(os.Stdin) || terminal.IsTerminal(os.Stdout) || terminal.IsTerminal(os.Stderr)
}

// Run starts cmd, waits for it to exit, and returns the same error cmd.Run
// would. It ensures SIGINT/SIGTERM reach the wrapped tool exactly once so it can
// clean up instead of being SIGKILLed the instant lstk's context is cancelled.
//
// When cmd was created with exec.CommandContext, its default Cancel hard-kills
// the child (SIGKILL) as soon as the context is done — which is exactly what
// happens on Ctrl-C, since lstk cancels its root context on SIGINT/SIGTERM. Run
// disarms that: a cancelled context no longer kills the child, so the tool
// terminates from the signal it receives and lstk waits for it to finish its own
// shutdown. Returning os.ErrProcessDone from Cancel both suppresses the kill and
// avoids injecting context.Canceled into the wait result, so the tool's real
// exit code is preserved.
//
// Received signals are relayed per shouldForward: SIGTERM always, SIGINT only
// when lstk is not attached to a terminal (an attached terminal already
// delivers Ctrl-C to the child via the foreground process group). npm/launcher.js
// forwards unconditionally instead — safe there because its child is lstk,
// which tolerates duplicate signals; wrapped tools like terraform do not.
func Run(cmd *exec.Cmd) error {
// Disarm exec.CommandContext's SIGKILL-on-cancel (no-op for a plain
// exec.Command, whose Cancel is nil).
if cmd.Cancel != nil {
cmd.Cancel = func() error { return os.ErrProcessDone }
}

attached := attachedToTerminal()

// Register before Start so a signal landing during startup is queued and
// relayed once the child is running, not silently swallowed by lstk's own
// root handler.
sigCh := make(chan os.Signal, len(forwardedSignals))
signal.Notify(sigCh, forwardedSignals...)
defer signal.Stop(sigCh)

if err := cmd.Start(); err != nil {
return err
}

done := make(chan struct{})
defer close(done)
go func() {
for {
select {
case sig := <-sigCh:
if shouldForward(sig, attached) {
_ = cmd.Process.Signal(sig)
}
case <-done:
return
}
}
}()

return cmd.Wait()
}
108 changes: 108 additions & 0 deletions internal/proc/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package proc

import (
"context"
"errors"
"os"
"os/exec"
"runtime"
"syscall"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// requireUnixShell skips tests that exec `sh`, which is unavailable on Windows
// (CI runs unit tests on Linux only, but developers run them anywhere).
func requireUnixShell(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("test execs `sh`, unavailable on Windows")
}
}

// TestShouldForward pins the forwarding policy: SIGTERM is never generated by a
// terminal, so it is always forwarded; SIGINT reaches the child via the
// controlling terminal's foreground process group when lstk is attached to a
// terminal, so forwarding there would double-signal the tool (terraform aborts
// on a second interrupt) and is suppressed.
func TestShouldForward(t *testing.T) {
t.Parallel()

assert.True(t, shouldForward(syscall.SIGTERM, true), "SIGTERM must forward even when attached to a terminal")
assert.True(t, shouldForward(syscall.SIGTERM, false), "SIGTERM must forward when detached")
assert.False(t, shouldForward(os.Interrupt, true), "SIGINT must not forward when attached to a terminal")
assert.True(t, shouldForward(os.Interrupt, false), "SIGINT must forward when detached")
}

// TestRunPropagatesExitCode verifies a wrapped tool's non-zero exit surfaces as
// an *exec.ExitError, matching cmd.Run's behaviour.
func TestRunPropagatesExitCode(t *testing.T) {
t.Parallel()
requireUnixShell(t)

cmd := exec.Command("sh", "-c", "exit 7")
err := Run(cmd)

var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 7, exitErr.ExitCode())
}

// TestRunSucceeds verifies a zero-exit tool returns nil.
func TestRunSucceeds(t *testing.T) {
t.Parallel()
requireUnixShell(t)

require.NoError(t, Run(exec.Command("sh", "-c", "exit 0")))
}

// TestRunDoesNotKillOnContextCancel is the regression test for the reported bug:
// a cancelled context must no longer SIGKILL the wrapped tool. The child sleeps
// briefly then exits 0 on its own; if Run left exec.CommandContext's default
// Cancel in place, the cancellation would kill the child and Run would report a
// non-nil error (a signal-terminated ExitError or context.Canceled) instead of
// the clean exit.
func TestRunDoesNotKillOnContextCancel(t *testing.T) {
t.Parallel()
requireUnixShell(t)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 0.3; exit 0")

go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()

err := Run(cmd)
require.NoError(t, err)
assert.True(t, cmd.ProcessState.Success(), "child should have exited on its own, not been killed")
}

// TestRunPreservesExitCodeOnContextCancel ensures the tool's real exit code
// survives a mid-run cancellation rather than being clobbered by context.Canceled.
func TestRunPreservesExitCodeOnContextCancel(t *testing.T) {
t.Parallel()
requireUnixShell(t)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 0.3; exit 5")

go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()

err := Run(cmd)
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 5, exitErr.ExitCode())
assert.False(t, errors.Is(err, context.Canceled))
}
Loading
Loading