diff --git a/cmd/root.go b/cmd/root.go index d22f1e4..13a79ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 @@ -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 } diff --git a/cmd/wrap.go b/cmd/wrap.go index 7f21c8b..858cdb0 100644 --- a/cmd/wrap.go +++ b/cmd/wrap.go @@ -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) { diff --git a/cmd/wrap_test.go b/cmd/wrap_test.go new file mode 100644 index 0000000..70c14a7 --- /dev/null +++ b/cmd/wrap_test.go @@ -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) + } +}