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
24 changes: 21 additions & 3 deletions go/internal/runner/agent_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,28 @@ func (s *AgentStream) endDrains() {
}

// isDeliberateKill reports whether err is the exit of a process we SIGKILLed on
// purpose — an *exec.ExitError whose wait status is "terminated by SIGKILL".
// That is exactly the outcome Terminate produces on the deliberate-teardown
// path, so Stop treats it as success while still surfacing any other failure.
// purpose, so Stop treats it as success while still surfacing any other
// failure. Two backends produce that outcome in two shapes:
//
// - The microVM GuestExec ChildHandle waitFunc returns a portable
// *runtime.ExitStatusError; a remote guest child's exit cannot be reported
// as an *exec.ExitError (it embeds an unforgeable *os.ProcessState), so the
// portable type is checked FIRST — a deliberate signal counts as a kill.
// - The podman backend's Wait returns an *exec.ExitError whose wait status is
// "terminated by SIGKILL"; that branch is unchanged, so the podman
// byte-path is byte-identical (OQ-G/U3b).
func isDeliberateKill(err error) bool {
var exitStatus *runtime.ExitStatusError
if errors.As(err, &exitStatus) {
// The two branches are deliberately asymmetric (OQ-G): the portable
// branch counts ANY signalled exit as a kill, while the podman branch
// below pins SIGKILL. That is intentional — the guest reports a
// deliberate teardown as SIGKILL (Kill) or SIGTERM (Stop), and OQ-G
// blessed Signal!=0 rather than enumerating signals. Do NOT "align" the
// two by narrowing this to SIGKILL: the microVM path has no os.ProcessState
// to inspect, and Stop's SIGTERM teardown must still classify as a kill.
return exitStatus.Signal != 0
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return false
Expand Down
89 changes: 89 additions & 0 deletions go/internal/runner/deliberate_kill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//go:build unix

package runner

// isDeliberateKill's widened taxonomy (U3b/OQ-G): it accepts both a real
// *exec.ExitError from a SIGKILLed local child (the podman byte-path, unchanged)
// and the portable *runtime.ExitStatusError a remote (microVM) waitFunc
// constructs, and rejects a non-signal exit and an unrelated error. Hermetic:
// the podman-path case SIGKILLs a real short-lived child, the rest are
// constructed errors; no KVM, no backend.

import (
"errors"
"os/exec"
"syscall"
"testing"

"github.com/RigelBuild/compass/go/internal/runtime"
)

// sigkilledExitError runs a trivial child and SIGKILLs it, returning the
// *exec.ExitError its Wait yields — the exact shape the podman ChildHandle.Wait
// produces on a deliberate teardown, so the regression guard exercises a real
// wait status rather than a hand-built one.
func sigkilledExitError(t *testing.T) error {
t.Helper()
cmd := exec.Command("sleep", "60")
if err := cmd.Start(); err != nil {
t.Fatalf("starting child: %v", err)
}
if err := cmd.Process.Signal(syscall.SIGKILL); err != nil {
t.Fatalf("sigkilling child: %v", err)
}
err := cmd.Wait()
if err == nil {
t.Fatal("expected a non-nil wait error for a SIGKILLed child")
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("wait error is %T, want *exec.ExitError", err)
}
return err
}

func TestIsDeliberateKill(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "podman-path *exec.ExitError SIGKILL",
err: sigkilledExitError(t),
want: true,
},
{
name: "portable ExitStatusError SIGKILL",
err: &runtime.ExitStatusError{Signal: syscall.SIGKILL},
want: true,
},
{
name: "portable ExitStatusError SIGTERM is still a deliberate signal",
err: &runtime.ExitStatusError{Signal: syscall.SIGTERM},
want: true,
},
{
name: "portable ExitStatusError non-signal exit is not a kill",
err: &runtime.ExitStatusError{Code: 1},
want: false,
},
{
name: "unrelated error",
err: errors.New("connection reset"),
want: false,
},
{
name: "nil error",
err: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDeliberateKill(tt.err); got != tt.want {
t.Fatalf("isDeliberateKill(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
95 changes: 95 additions & 0 deletions go/internal/runtime/child_handle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package runtime

// newChildHandleFuncs (U3): the funcs-backed ChildHandle the microVM GuestExec
// adaptation consumes (U4 wires the real kill/wait pair onto a GuestStream).
// These hermetic unit tests pin the exported Kill/Wait/Terminate surface over a
// kill/wait function pair — no *exec.Cmd, no backend — including that a
// signalled-exit waitFunc returning *ExitStatusError flows through Wait
// unchanged, so the runner's isDeliberateKill can recognize it.

import (
"errors"
"syscall"
"testing"
)

func TestNewChildHandleFuncs_Kill(t *testing.T) {
killed := false
h := newChildHandleFuncs(
func() error { killed = true; return nil },
func() error { return nil },
)
if err := h.Kill(); err != nil {
t.Fatalf("Kill: %v", err)
}
if !killed {
t.Fatal("Kill did not invoke killFunc")
}
}

func TestNewChildHandleFuncs_KillError(t *testing.T) {
want := errors.New("transport wedged")
h := newChildHandleFuncs(
func() error { return want },
func() error { return nil },
)
if err := h.Kill(); !errors.Is(err, want) {
t.Fatalf("Kill error = %v, want %v", err, want)
}
}

func TestNewChildHandleFuncs_WaitExitZero(t *testing.T) {
h := newChildHandleFuncs(
func() error { return nil },
func() error { return nil },
)
if err := h.Wait(); err != nil {
t.Fatalf("Wait on exit 0 = %v, want nil", err)
}
}

func TestNewChildHandleFuncs_WaitSignalledExit(t *testing.T) {
// A signalled guest exit: waitFunc returns the portable *ExitStatusError,
// which Wait must surface unchanged so isDeliberateKill recognizes it.
h := newChildHandleFuncs(
func() error { return nil },
func() error { return &ExitStatusError{Signal: syscall.SIGKILL} },
)
err := h.Wait()
var exitStatus *ExitStatusError
if !errors.As(err, &exitStatus) {
t.Fatalf("Wait error = %T, want *ExitStatusError", err)
}
if exitStatus.Signal != syscall.SIGKILL {
t.Fatalf("signal = %v, want SIGKILL", exitStatus.Signal)
}
}

func TestNewChildHandleFuncs_TerminateReturnsWaitError(t *testing.T) {
// Terminate is Kill then Wait; the wait error (the exit status) is what a
// caller distinguishing crash-from-teardown needs, so it wins over the kill
// error.
killErr := errors.New("signal RPC timed out")
h := newChildHandleFuncs(
func() error { return killErr },
func() error { return &ExitStatusError{Signal: syscall.SIGKILL} },
)
err := h.Terminate()
var exitStatus *ExitStatusError
if !errors.As(err, &exitStatus) {
t.Fatalf("Terminate error = %T, want *ExitStatusError (the wait error)", err)
}
}

func TestNewChildHandleFuncs_TerminateExitZero(t *testing.T) {
// Clean exit with a kill error: Terminate returns the kill error, since the
// wait error is nil.
killErr := errors.New("signal RPC timed out")
h := newChildHandleFuncs(
func() error { return killErr },
func() error { return nil },
)
if err := h.Terminate(); !errors.Is(err, killErr) {
t.Fatalf("Terminate error = %v, want the kill error %v", err, killErr)
}
}
38 changes: 38 additions & 0 deletions go/internal/runtime/exit_status_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package runtime

import (
"fmt"
"syscall"
)

// ExitStatusError is a backend-portable process-exit error the runner's
// isDeliberateKill recognizes, so a remote (microVM) guest exit can be told
// from a crash without fabricating an *exec.ExitError.
//
// The podman backend reports a deliberate SIGKILL teardown as an
// *exec.ExitError whose syscall.WaitStatus is Signaled()+SIGKILL
// (agent_exec.go isDeliberateKill). A remote exec (the microVM GuestExec
// ChildHandle waitFunc) cannot construct an *exec.ExitError: it embeds
// *os.ProcessState, which has unexported fields and no public constructor, so
// a waitFunc reporting a guest child's exit has no way to forge one. This
// exported concrete type is the portable stand-in — a plain (code, signal)
// pair the microVM waitFunc returns and isDeliberateKill matches with
// errors.As, alongside the existing *exec.ExitError branch so the podman
// byte-path is unchanged (OQ-G, design §(e)). It is a concrete struct rather
// than an interface: it is the simplest errors.As target and no caller needs
// the abstraction today.
type ExitStatusError struct {
// Code is the child's exit code, meaningful when Signal == 0.
Code int
// Signal is the terminating signal, non-zero when the child died by signal
// (e.g. syscall.SIGKILL on a deliberate Kill).
Signal syscall.Signal
}

// Error describes the exit as either a signal death or a non-zero exit code.
func (e *ExitStatusError) Error() string {
if e.Signal != 0 {
return fmt.Sprintf("process terminated by signal %d (%s)", int(e.Signal), e.Signal)
}
return fmt.Sprintf("process exited with code %d", e.Code)
}
Loading
Loading