From b787667c586864c79cb02ba3dea0ad9786ec36e4 Mon Sep 17 00:00:00 2001 From: Contagent Date: Tue, 17 Mar 2026 23:13:46 +0000 Subject: [PATCH] Fix container hang when Ctrl-C is pressed on apple runtime Wrap the user command in a login shell in Container.Attach to work around PATH resolution bugs in apple/containerization <= 0.26.3. Replace direct signal handling in Container.Wait with ctx.Done() so Ctrl-C correctly triggers container shutdown. Add a 10-second timeout context to ForceRemove in the cleanup handler. --- internal/apple/container.go | 29 ++++++++++++++++++----------- internal/apple/container_test.go | 8 ++++++-- main.go | 3 +++ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/internal/apple/container.go b/internal/apple/container.go index f8dec2a..7e47e1a 100644 --- a/internal/apple/container.go +++ b/internal/apple/container.go @@ -6,10 +6,8 @@ import ( "fmt" "io" "os" - "os/signal" "strconv" "strings" - "syscall" "time" "github.com/ryanmoran/contagent/internal" @@ -140,7 +138,21 @@ func (c *Container) Attach(ctx context.Context, cancel context.CancelFunc, w int } args = append(args, c.name) - args = append(args, c.cmd...) + + // TODO: Remove this login shell workaround once apple/container ships a release + // that includes apple/containerization >= 0.26.5. Two bugs in containerization + // (PRs #550 and #562, merged Feb 2026) caused `container exec` to ignore the + // container's configured PATH and environment entirely, falling back to a hardcoded + // default PATH that omits user-local directories (e.g. ~/.local/bin). Both fixes + // landed in containerization 0.26.5 and are present in apple/container main, but + // the latest release (v0.10.0) pins containerization 0.26.3 and does not include + // them. Wrapping in a login shell sources profile files and resolves PATH correctly + // regardless of the containerization version in use. + quoted := make([]string, len(c.cmd)) + for i, arg := range c.cmd { + quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" + } + args = append(args, "/bin/sh", "-l", "-c", "exec "+strings.Join(quoted, " ")) proc, err := c.runner.Start(ctx, os.Stdin, os.Stdout, os.Stderr, "container", args...) if err != nil { @@ -152,7 +164,8 @@ func (c *Container) Attach(ctx context.Context, cancel context.CancelFunc, w int } // Wait waits for the exec process (started in Attach) to exit. -// It handles SIGINT/SIGTERM by stopping the container gracefully. +// It handles context cancellation (e.g. from SIGINT/SIGTERM) by stopping the +// container gracefully before waiting for the exec process to exit. func (c *Container) Wait(ctx context.Context, w internal.Writer) error { if c.process == nil { return nil @@ -169,17 +182,13 @@ func (c *Container) Wait(ctx context.Context, w internal.Writer) error { done <- result{code, err} }() - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(sigChan) - select { case r := <-done: if r.err != nil { return fmt.Errorf("process error in container %q: %w", c.name, r.err) } w.Printf("\nContainer exited with status: %d\n", r.exitCode) - case <-sigChan: + case <-ctx.Done(): w.Println("\nReceived signal, stopping container...") stopCtx := context.Background() if err := c.runner.Run(stopCtx, nil, nil, nil, @@ -190,8 +199,6 @@ func (c *Container) Wait(ctx context.Context, w internal.Writer) error { w.Warningf("failed to stop container: %v", err) } <-done - case <-ctx.Done(): - <-done } return nil diff --git a/internal/apple/container_test.go b/internal/apple/container_test.go index a2a6f3b..ec9801c 100644 --- a/internal/apple/container_test.go +++ b/internal/apple/container_test.go @@ -231,8 +231,12 @@ func TestContainerAttach(t *testing.T) { require.Contains(t, argsStr, "--env") require.Contains(t, argsStr, "FOO=bar") require.Contains(t, argsStr, "test-session") - require.Contains(t, argsStr, "echo") - require.Contains(t, argsStr, "hello") + // Command is wrapped in a login shell to ensure profile files are sourced + // and PATH includes user-local directories. See TODO in container.go. + require.Contains(t, argsStr, "/bin/sh") + require.Contains(t, argsStr, "-l") + require.Contains(t, argsStr, "-c") + require.Contains(t, argsStr, "exec 'echo' 'hello'") }) t.Run("returns error on exec failure", func(t *testing.T) { diff --git a/main.go b/main.go index 88bd938..1250d27 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "path/filepath" "syscall" + "time" "github.com/ryanmoran/contagent/internal" "github.com/ryanmoran/contagent/internal/apple" @@ -136,6 +137,8 @@ func run(args, env []string) error { return fmt.Errorf("failed to create container %q from image %q: %w", session.ID(), image.Name, err) } cleanup.Add("container", func() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() return container.ForceRemove(ctx) })