WithStartupCommand silently swallows non-zero exit codes from the inner command
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 132
- Forks
- 26
- PR merge metrics
- No merged PRs in 30d
Description
Summary
container.WithStartupCommand registers a PostStarts lifecycle hook that runs each Executable via Container.Exec. When the inner command exits non-zero, the hook returns nil and container.Run reports success. The container ends up in a "running but the startup command failed" state with no visible signal to the caller.
Where
-
The hook is built in
container/options.go(createExecutableHooks):execFn := func(ctx context.Context, c ContainerInfo) error { if executor, ok := c.(ContainerExecutor); ok { _, _, err := executor.Exec(ctx, exec.AsCommand(), exec.Options()...) return err } return errors.New("container does not support execution") } -
Container.Execincontainer/container.exec.goreturns(int, io.Reader, error). The first return is the inner process's exit code — a value, not an error. The hook discards it.
Reproduction
c, err := container.Run(ctx,
container.WithImage("alpine:latest"),
container.WithEntrypoint("tail", "-f", "/dev/null"),
container.WithStartupCommand(exec.NewRawCommand([]string{"false"})),
)
// err == nil; c is up; the failing startup command is invisible.
This bites consumers in two ways:
- First-create coverage doesn't fail loudly. A startup command that errors is silently ignored at create time. The caller has no way to discover the failure short of checking side effects.
- The same trap applies to
WithAfterReadyCommand(same hook factory).
I hit this while writing tests for WithDurableStartupCommand (#154): a WithUser("nonexistent") case where su aborts, the rendered dispatcher exits non-zero, but Run still succeeds. I had to invoke the dispatcher directly via c.Exec and check the exit code value to observe the failure.
Suggested fix
Have createExecutableHooks (or a thin helper) treat a non-zero exit code as an error:
execFn := func(ctx context.Context, c ContainerInfo) error {
executor, ok := c.(ContainerExecutor)
if !ok {
return errors.New("container does not support execution")
}
code, _, err := executor.Exec(ctx, exec.AsCommand(), exec.Options()...)
if err != nil {
return err
}
if code != 0 {
return fmt.Errorf("startup command %v exited with code %d", exec.AsCommand(), code)
}
return nil
}
This is a behavioural change for any caller that today relies on startup-command failures being invisible — but I'd expect that population to be small, and the silent-failure mode is more dangerous than the loud one.
If a strictly backwards-compatible variant is preferred, an opt-in option (WithStrictStartupCommand?) could keep the current behaviour as the default.
Out of scope (but related)
Container.Exec's signature returning(int, io.Reader, error)makes the exit code easy to ignore. A future API revision could fold non-zero into theerrorreturn; for now, fixing the hook is the smaller change.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in container/options.go at createExecutableHooks, then inspect Container.Exec in container/container.exec.go to confirm how exit codes and errors are returned. The change is complete when non-zero codes from WithStartupCommand and WithAfterReadyCommand are reported as failures while zero codes and execution errors retain their expected behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, go
- Domain
- backend-api-design, devtools
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100