From 81f4599f3feb131fe8bd4359c10638134566f715 Mon Sep 17 00:00:00 2001 From: Delaida Muminovic Date: Fri, 3 Jul 2026 18:52:33 +0200 Subject: [PATCH] fix: respond to SIGTERM/SIGINT in default init script --- README.md | 1 + docs/env-variables.md | 2 +- integration/integration_test.go | 37 +++++++++++++++++++++++++++++++++ options/defaults.go | 2 +- options/defaults_test.go | 2 +- options/options.go | 8 ++++--- options/testdata/options.golden | 5 +++-- 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6bf3653c..53e12f6b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ To explore more examples, tips, and advanced usage, check out the following guid ## Setup Script The `ENVBUILDER_SETUP_SCRIPT` environment variable dynamically configures the user and init command (PID 1) after the container build process. +Custom `ENVBUILDER_INIT_SCRIPT` and `ENVBUILDER_INIT_COMMAND` values become PID 1, so they must trap signals themselves if they need prompt `SIGTERM` or `SIGINT` handling. > **Note**: `TARGET_USER` is passed to the setup script to specify who will execute `ENVBUILDER_INIT_COMMAND` (e.g., `code`). diff --git a/docs/env-variables.md b/docs/env-variables.md index e49e2a62..30c936ba 100644 --- a/docs/env-variables.md +++ b/docs/env-variables.md @@ -4,7 +4,7 @@ | Flag | Environment variable | Default | Description | | - | - | - | - | | `--setup-script` | `ENVBUILDER_SETUP_SCRIPT` | | The script to run before the init script. It runs as the root user regardless of the user specified in the devcontainer.json file. SetupScript is ran as the root user prior to the init script. It is used to configure envbuilder dynamically during the runtime. e.g. specifying whether to start systemd or tiny init for PID 1. | -| `--init-script` | `ENVBUILDER_INIT_SCRIPT` | | The script to run to initialize the workspace. Default: `sleep infinity`. | +| `--init-script` | `ENVBUILDER_INIT_SCRIPT` | | The script to run to initialize the workspace. By default, this traps `SIGTERM`/`SIGINT`, starts `sleep infinity` as a child process, and waits for it so the container exits promptly when stopped. | | `--init-command` | `ENVBUILDER_INIT_COMMAND` | | The command to run to initialize the workspace. Default: `/bin/sh`. | | `--init-args` | `ENVBUILDER_INIT_ARGS` | | The arguments to pass to the init command. They are split according to /bin/sh rules with https://github.com/kballard/go-shellquote. | | `--cache-repo` | `ENVBUILDER_CACHE_REPO` | | The name of the container registry to push the cache image to. If this is empty, the cache will not be pushed. | diff --git a/integration/integration_test.go b/integration/integration_test.go index 82f0c4c8..9f134dd2 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1088,6 +1088,43 @@ func TestBuildStopStartCached(t *testing.T) { } } +func TestDefaultInitScriptRespondsToSignals(t *testing.T) { + t.Parallel() + + for _, signal := range []string{"SIGTERM", "SIGINT"} { + t.Run(signal, func(t *testing.T) { + t.Parallel() + + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + "Dockerfile": "FROM " + testImageAlpine, + }, + }) + ctr, err := runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + }}) + require.NoError(t, err) + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + require.NoError(t, err) + defer cli.Close() + + ctx := context.Background() + err = cli.ContainerKill(ctx, ctr, signal) + require.NoError(t, err) + + require.Eventually(t, func() bool { + status, err := cli.ContainerInspect(ctx, ctr) + if !assert.NoError(t, err) { + return false + } + return !status.State.Running + }, 5*time.Second, 100*time.Millisecond, "container never exited") + }) + } +} + func TestCloneFailsFallback(t *testing.T) { t.Parallel() t.Run("BadRepo", func(t *testing.T) { diff --git a/options/defaults.go b/options/defaults.go index c7aca9f3..cbacdb7a 100644 --- a/options/defaults.go +++ b/options/defaults.go @@ -50,7 +50,7 @@ func (o *Options) SetDefaults() { } } if o.InitScript == "" { - o.InitScript = "sleep infinity" + o.InitScript = "trap 'exit 0' TERM INT\nsleep infinity &\nwait $!" } if o.InitCommand == "" { o.InitCommand = "/bin/sh" diff --git a/options/defaults_test.go b/options/defaults_test.go index edc2bf3c..b8ba50b5 100644 --- a/options/defaults_test.go +++ b/options/defaults_test.go @@ -180,7 +180,7 @@ func TestOptions_SetDefaults(t *testing.T) { t.Parallel() expected := options.Options{ - InitScript: "sleep infinity", + InitScript: "trap 'exit 0' TERM INT\nsleep infinity &\nwait $!", InitCommand: "/bin/sh", IgnorePaths: []string{"/var/run", "/product_uuid", "/product_name"}, Filesystem: chmodfs.New(osfs.New("/")), diff --git a/options/options.go b/options/options.go index 69048b59..d4785d77 100644 --- a/options/options.go +++ b/options/options.go @@ -253,9 +253,11 @@ func (o *Options) CLI() serpent.OptionSet { { Flag: "init-script", Env: WithEnvPrefix("INIT_SCRIPT"), - // Default: "sleep infinity", // TODO: reinstate once legacy opts are removed. - Value: serpent.StringOf(&o.InitScript), - Description: "The script to run to initialize the workspace. Default: `sleep infinity`.", + // Default: "trap 'exit 0' TERM INT\nsleep infinity &\nwait $!", // TODO: reinstate once legacy opts are removed. + Value: serpent.StringOf(&o.InitScript), + Description: "The script to run to initialize the workspace. By default, this traps " + + "`SIGTERM`/`SIGINT`, starts `sleep infinity` as a child process, and waits for it " + + "so the container exits promptly when stopped.", }, { Flag: "init-command", diff --git a/options/testdata/options.golden b/options/testdata/options.golden index 918b3dde..38d0c1fc 100644 --- a/options/testdata/options.golden +++ b/options/testdata/options.golden @@ -142,8 +142,9 @@ OPTIONS: The command to run to initialize the workspace. Default: `/bin/sh`. --init-script string, $ENVBUILDER_INIT_SCRIPT - The script to run to initialize the workspace. Default: `sleep - infinity`. + The script to run to initialize the workspace. By default, this traps + `SIGTERM`/`SIGINT`, starts `sleep infinity` as a child process, and + waits for it so the container exits promptly when stopped. --insecure bool, $ENVBUILDER_INSECURE Bypass TLS verification when cloning and pulling from container