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
26 changes: 26 additions & 0 deletions data_for_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,32 @@ var suites = []FixtureSuite{
},
},
},
// #360: exec child exit code should propagate even when OTLP export fails
{
{
Name: "#360 exec child exit code preserved when export fails",
Config: FixtureConfig{
CliArgs: []string{"exec",
"--endpoint", "{{endpoint}}",
"--timeout", "100ms",
"--", "/bin/sh", "-c", "exit 42",
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coding /bin/sh makes this fixture non-portable (e.g., Windows runners). If CI/test execution targets multiple OSes, consider using an OS-appropriate command (or a small helper test binary) or adding a runtime/platform skip/guard in the fixture system for non-POSIX environments.

Copilot uses AI. Check for mistakes.
},
StopServerBeforeExec: true,
TestTimeoutMs: 2000,
IsLongTest: true,
},
Expect: Results{
Config: otelcli.DefaultConfig(),
},
CheckFuncs: []CheckFunc{
func(t *testing.T, f Fixture, r Results) {
if r.ExitCode != 42 {
t.Errorf("expected exit code 42 from child process, got %d", r.ExitCode)
}
},
},
},
},
Comment on lines +571 to +596
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test asserts the exit code, but it doesn’t assert that the OTLP export actually failed (the condition that triggers the regression). This can produce a false positive if export doesn’t error for some reason. Consider adding an assertion that the run logged/returned an export/client.Stop() error (e.g., checking captured stderr/output or a structured error field in Results, whichever the harness provides).

Copilot uses AI. Check for mistakes.
// otel-cli span with no OTLP config should do and print nothing
{
{
Expand Down
9 changes: 7 additions & 2 deletions otelcli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,11 +373,16 @@ func (c Config) SoftLogIfErr(err error) {
}

// SoftFail calls through to softLog (which logs only if otel-cli was run with the --verbose
// flag), then immediately exits - with status -1 by default, or 1 if --fail was
// set (a la `curl --fail`)
// flag), then immediately exits. When a child process exit code has been captured (e.g. from
// exec), that code is preserved. Otherwise exits 0 by default, or 1 with --fail.
func (c Config) SoftFail(format string, a ...interface{}) {
c.SoftLog(format, a...)

// preserve exec child exit code when available (#360)
if Diag.ExecExitCode != 0 {
os.Exit(Diag.ExecExitCode)
}

if c.Fail {
os.Exit(1)
} else {
Expand Down
17 changes: 7 additions & 10 deletions otelcli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ func doExec(cmd *cobra.Command, args []string) {
span.Attributes = append(span.Attributes, pidAttrs...)
}

// capture the child's exit code before OTLP export so SoftFail can use it (#360)
if child.ProcessState != nil {
Diag.ExecExitCode = child.ProcessState.ExitCode()
} else {
Diag.ExecExitCode = 127
}

cancelCtxDeadline()
close(signals)
<-signalsDone
Expand All @@ -171,16 +178,6 @@ func doExec(cmd *cobra.Command, args []string) {
config.SoftFail("client.Stop() failed: %s", err)
}

// set the global exit code so main() can grab it and os.Exit() properly
// ProcessState is nil if the command failed to start
if child.ProcessState != nil {
Diag.ExecExitCode = child.ProcessState.ExitCode()
} else {
// command failed to start (e.g., command not found)
// use exit code 127 to match shell behavior for "command not found"
Diag.ExecExitCode = 127
}

config.PropagateTraceparent(span, os.Stdout)
}

Expand Down