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
11 changes: 11 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ var (
cmdFullPath string
)

// childExitCode is the exit code of a wrapped child command that failed
// after producing output. runWrap records it via recordChildExit; Execute
// applies it with os.Exit only after the completion event has fired and
// analytics have been flushed.
var childExitCode int

const (
autoUpdateInterval = 24 * time.Hour
autoUpdateTimeout = 2 * time.Second
Expand Down Expand Up @@ -78,6 +84,11 @@ func Execute() error {
analytics.Capture(event, props)
}
analytics.Shutdown()
// A wrapped child failed; propagate its exit code now that the
// completion event has fired and the analytics queue is flushed.
if err == nil && childExitCode != 0 {
os.Exit(childExitCode)
}
return err
}

Expand Down
20 changes: 14 additions & 6 deletions cmd/wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,21 @@ func runWrap(cmd *cobra.Command, args []string) error {

// If the child failed, surface its exit code so callers/agents see it.
// (No-op for the stdin path — `err` is nil there.)
if err != nil {
if exitErr, ok := err.(*osexec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
return err
return recordChildExit(err)
}

// recordChildExit defers a failed child's exit code to Execute instead of
// os.Exit-ing here. Exiting mid-command skips the cli_command_completed
// event and the analytics flush in Execute, so the PostHog client (which
// batches on a 5s interval) silently drops the events for exactly the
// failed-child runs. Non-exit errors pass through unchanged.
func recordChildExit(err error) error {
var exitErr *osexec.ExitError
if errors.As(err, &exitErr) {
childExitCode = exitErr.ExitCode()
return nil
}
return nil
return err
}

func readChildOutput(args []string) (lines []string, runErr error, fatalErr error) {
Expand Down
58 changes: 58 additions & 0 deletions cmd/wrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cmd

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

// TestRecordChildExitDefersExitCode verifies that a failed child's exit
// code is recorded for Execute to apply after the analytics flush instead
// of os.Exit-ing inside the command, which would skip the completion event
// and drop buffered telemetry for exactly the failed-child runs.
func TestRecordChildExitDefersExitCode(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=TestTelemetryExitHelper")
cmd.Env = append(os.Environ(), "CODAG_TELEMETRY_TEST_EXIT=1")
runErr := cmd.Run()

var exitErr *exec.ExitError
if !errors.As(runErr, &exitErr) {
t.Fatalf("expected ExitError, got %T", runErr)
}

childExitCode = 0
t.Cleanup(func() { childExitCode = 0 })

if got := recordChildExit(runErr); got != nil {
t.Fatalf("recordChildExit(ExitError) = %v, want nil", got)
}
if childExitCode != 7 {
t.Fatalf("childExitCode = %d, want 7", childExitCode)
}
}

func TestRecordChildExitPassesThroughOtherErrors(t *testing.T) {
childExitCode = 0
t.Cleanup(func() { childExitCode = 0 })

sentinel := errors.New("not an exit error")
if got := recordChildExit(sentinel); !errors.Is(got, sentinel) {
t.Fatalf("recordChildExit(non-exit error) = %v, want passthrough", got)
}
if childExitCode != 0 {
t.Fatalf("childExitCode = %d, want 0 for non-exit error", childExitCode)
}
}

func TestRecordChildExitNilIsNoop(t *testing.T) {
childExitCode = 0
t.Cleanup(func() { childExitCode = 0 })

if got := recordChildExit(nil); got != nil {
t.Fatalf("recordChildExit(nil) = %v, want nil", got)
}
if childExitCode != 0 {
t.Fatalf("childExitCode = %d, want 0 for nil error", childExitCode)
}
}