Skip to content
Merged
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
29 changes: 18 additions & 11 deletions internal/apple/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import (
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"

"github.com/ryanmoran/contagent/internal"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions internal/apple/container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/signal"
"path/filepath"
"syscall"
"time"

"github.com/ryanmoran/contagent/internal"
"github.com/ryanmoran/contagent/internal/apple"
Expand Down Expand Up @@ -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)
})

Expand Down
Loading