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
24 changes: 24 additions & 0 deletions changelogs/v0.18-current.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@

## [Unreleased]

### Fixed — `exit()` in one batch item killed the whole batch run (#607)

`ailang run --batch` promises per-item isolation: it runs the entrypoint once per input, counts
failures, and prints `Batch complete: X/Y succeeded`. It did not deliver that when an item called
`exit()`.

`exit()` raises a `*eval.EvalExitCode` sentinel panic (`internal/effects/io.go`). The single-file
run path recovers that sentinel and turns it into a clean `os.Exit`; `executeBatchItem` called
`executeModuleEntrypoint` directly with **no recover**, so the sentinel unwound through the batch
loop and out of `main`. The process died with **rc=2** and a raw Go stack trace, and every
remaining input was silently skipped — reported from a 2,500-file PDF batch job, where one bad
file aborted the run.

The recover already existed; one call site did not have it. Now `exit(N)` with `N != 0` fails
**that item** (the loop reports it and continues to the next input), `exit(0)` counts as a
success, and non-exit panics are re-raised unchanged so a genuine crash stays loud.

Before: `[1/2]` panics, rc=2, Go stack, `[2/2]` never runs.
After: `[1/2]` reports `program called exit(1)`, `[2/2]` runs, `Batch complete: 1/2 succeeded`, rc=1.

Batch mode had no regression tests at all; it has five now, covering all four recover branches
(including the re-panic arm, which no `.ail` fixture can reach) plus a single-file control that
distinguishes a batch-mode regression from one in the shared `exit()` mechanism.

### Fixed — the stdlib interface-freeze gate had been dead since v0.0.12

`make verify-stdlib` failed on a clean tree and had done so for ~33 minor versions. It was not
Expand Down
239 changes: 239 additions & 0 deletions cmd/ailang/main_run_batch_exit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package main

import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/sunholo-data/ailang/internal/eval"
"github.com/sunholo-data/ailang/internal/testutil"
)

// batchExitProgram branches on its single batch input: BOOM calls exit(1),
// ZERO calls exit(0), anything else just prints. One compiled module can then
// play the failing item, the exit(0) item and the surviving item.
const batchExitProgram = `module main

import std/io (println, exit)
import std/env (getArgs)

export func main() -> () ! {IO, Env} =
match getArgs() {
[a] => if a == "BOOM" then let _ = println("BOOM: calling exit(1)") in exit(1)
else if a == "ZERO" then let _ = println("ZERO: calling exit(0)") in exit(0)
else println("item ran: ${a}")
_ => println("no args")
}
`

// runBatchExitFixture runs the fixture above in batch mode over the given
// inputs and returns (stdout+stderr, exit code).
func runBatchExitFixture(t *testing.T, inputs ...string) (string, int) {
t.Helper()

ailangBin := testutil.FindAilangBinary(t)

tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "main.ail")
if err := os.WriteFile(src, []byte(batchExitProgram), 0o644); err != nil {
t.Fatalf("write program: %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

args := append([]string{"run", "--caps", "IO,Env", "--entry", "main", "--batch", src}, inputs...)
cmd := exec.CommandContext(ctx, ailangBin, args...)
out, err := cmd.CombinedOutput()

code := 0
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("run %v: %v (output: %s)", args, err, out)
}
code = exitErr.ExitCode()
}
if ctx.Err() != nil {
t.Fatalf("batch run timed out; output: %s", out)
}
return string(out), code
}

// TestBatchMode_ExitInOneItemDoesNotKillRun pins #607.
//
// Before the fix, exit() inside a batch item raised the *eval.EvalExitCode
// sentinel through executeBatchItem — which had no recover — so the process
// died with rc=2 and a raw Go panic stack, and every later input was skipped.
// The reporter hit this on a 2,500-file PDF batch: one bad file aborted the
// whole job.
//
// This asserts the per-item-isolation contract that the "Batch complete: X/Y
// succeeded" summary already implies. Removing the recover in
// runBatchItemEntrypoint reds every arm below.
func TestBatchMode_ExitInOneItemDoesNotKillRun(t *testing.T) {
out, code := runBatchExitFixture(t, "BOOM", "SECOND")

// The defect's signature: a raw Go panic reaching the user.
if strings.Contains(out, "panic:") || strings.Contains(out, "goroutine 1 [running]") {
t.Errorf("batch run leaked a Go panic to the user (#607):\n%s", out)
}
if strings.Contains(out, "EvalExitCode") {
t.Errorf("exit() sentinel escaped as a panic value (#607):\n%s", out)
}

// The item that called exit(1) must be reported as failed, not silently
// dropped: the caller prints the error and counts it.
if !strings.Contains(out, "exit(1)") {
t.Errorf("failing item did not report its exit code; output:\n%s", out)
}

// The load-bearing assertion: the SECOND item still ran.
if !strings.Contains(out, "[2/2]") {
t.Errorf("batch stopped after the failing item — [2/2] never started:\n%s", out)
}
if !strings.Contains(out, "item ran: SECOND") {
t.Errorf("second batch item produced no output — it never executed:\n%s", out)
}
if !strings.Contains(out, "Batch complete: 1/2 succeeded") {
t.Errorf("batch summary missing or miscounted; want 1/2 succeeded:\n%s", out)
}

// A batch with a failed item exits non-zero, but cleanly (1, not the 2 a
// Go panic produces).
if code != 1 {
t.Errorf("exit code = %d, want 1 (clean failure, not a panic's 2):\n%s", code, out)
}
}

// TestBatchMode_ExitCodeZeroCountsAsSuccess pins the other half of the
// contract: exit(0) is a successful item, not a failed one, and likewise does
// not abort the run. The first input really does call exit(0) — asserted via
// its own stdout line, so this cannot pass on a program that never exits.
func TestBatchMode_ExitCodeZeroCountsAsSuccess(t *testing.T) {
out, code := runBatchExitFixture(t, "ZERO", "SECOND")

if strings.Contains(out, "panic:") {
t.Errorf("exit(0) batch leaked a panic:\n%s", out)
}
// Proof the exit(0) arm was actually taken, not the println arm.
if !strings.Contains(out, "ZERO: calling exit(0)") {
t.Fatalf("fixture never reached the exit(0) arm — test is vacuous:\n%s", out)
}
if !strings.Contains(out, "item ran: SECOND") {
t.Errorf("exit(0) aborted the batch — second item never ran:\n%s", out)
}
if !strings.Contains(out, "Batch complete: 2/2 succeeded") {
t.Errorf("exit(0) item was not counted as a success; want 2/2:\n%s", out)
}
if code != 0 {
t.Errorf("exit(0) batch exit code = %d, want 0:\n%s", code, out)
}
}

// TestBatchMode_CleanRunUnaffected is the negative control for the recover:
// a batch where no item exits must behave exactly as before the fix.
func TestBatchMode_CleanRunUnaffected(t *testing.T) {
out, code := runBatchExitFixture(t, "FIRST", "SECOND")

if strings.Contains(out, "panic:") {
t.Errorf("clean batch leaked a panic:\n%s", out)
}
if !strings.Contains(out, "Batch complete: 2/2 succeeded") {
t.Errorf("clean batch did not report 2/2; output:\n%s", out)
}
if code != 0 {
t.Errorf("clean batch exit code = %d, want 0:\n%s", code, out)
}
}

// TestRecoverBatchItemExit_Branches pins every branch of the recover directly,
// including the re-panic arm, which no .ail fixture can reach deterministically
// (it needs a genuine Go crash inside the evaluator). Without this, that branch
// would ship unguarded — a batch item that really crashes must stay loud rather
// than being silently downgraded to "this item failed".
func TestRecoverBatchItemExit_Branches(t *testing.T) {

Check failure on line 160 in cmd/ailang/main_run_batch_exit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=sunholo-data_ailang&issues=AZ_7GDWRP48jZEa23WmT&open=AZ_7GDWRP48jZEa23WmT&pullRequest=690
t.Run("no panic passes the error through unchanged", func(t *testing.T) {
want := errors.New("ordinary failure")
if got := recoverBatchItemExit(func() error { return want }); !errors.Is(got, want) {
t.Errorf("err = %v, want %v", got, want)
}
if got := recoverBatchItemExit(func() error { return nil }); got != nil {
t.Errorf("err = %v, want nil", got)
}
})

t.Run("non-zero exit becomes a per-item error", func(t *testing.T) {
got := recoverBatchItemExit(func() error {
panic(&eval.EvalExitCode{Code: 3})
})
if got == nil {
t.Fatal("exit(3) produced no error — the item would count as a success")
}
if !strings.Contains(got.Error(), "exit(3)") {
t.Errorf("err = %q, want it to name exit(3)", got.Error())
}
})

t.Run("exit zero is a success", func(t *testing.T) {
if got := recoverBatchItemExit(func() error {
panic(&eval.EvalExitCode{Code: 0})
}); got != nil {
t.Errorf("exit(0) err = %v, want nil", got)
}
})

t.Run("a real crash is re-panicked, not swallowed", func(t *testing.T) {
defer func() {
r := recover()
if r == nil {
t.Fatal("a non-exit panic was swallowed — genuine crashes would be " +
"silently reported as a failed batch item")
}
if s, ok := r.(string); !ok || s != "genuine crash" {
t.Errorf("re-panicked value = %v, want the original panic", r)
}
}()
_ = recoverBatchItemExit(func() error { panic("genuine crash") })
})
}

// TestSingleFileRun_ExitStaysClean is the path-specificity control: the
// single-file path already recovered the sentinel, and must keep doing so.
// If this ever reds alongside the batch tests, the regression is in the shared
// exit() mechanism rather than in batch mode.
func TestSingleFileRun_ExitStaysClean(t *testing.T) {
ailangBin := testutil.FindAilangBinary(t)

tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "main.ail")
if err := os.WriteFile(src, []byte(batchExitProgram), 0o644); err != nil {
t.Fatalf("write program: %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, ailangBin, "run", "--caps", "IO,Env", "--entry", "main", src, "BOOM")
out, err := cmd.CombinedOutput()

code := 0
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("run: %v (output: %s)", err, out)
}
code = exitErr.ExitCode()
}
if strings.Contains(string(out), "panic:") {
t.Errorf("single-file exit() leaked a panic:\n%s", out)
}
if code != 1 {
t.Errorf("single-file exit(1) code = %d, want 1:\n%s", code, out)
}
}
45 changes: 44 additions & 1 deletion cmd/ailang/run_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,50 @@ func executeBatchItem(ctx context.Context, result pipeline.Result, input string,
quiet: quiet,
pipelineResult: &result,
}
return executeModuleEntrypoint(rt, execParams)
return runBatchItemEntrypoint(rt, execParams)
}

// runBatchItemEntrypoint executes one batch item's entrypoint, converting the
// exit() sentinel panic into a per-item outcome.
//
// #607: exit() raises a *eval.EvalExitCode sentinel panic (effects/io.go). The
// single-file run path recovers it (main_run_exec.go) and turns it into a clean
// os.Exit; executeBatchItem called executeModuleEntrypoint directly, so the
// sentinel unwound through the whole batch loop — the process died with rc=2
// and a raw Go stack, and every remaining input was silently skipped. That is
// the guard-the-helper-miss-the-call-site shape: the recover existed, one call
// site did not have it.
//
// Batch mode's contract is per-item isolation — the "Batch complete: X/Y
// succeeded" summary already promises it — so a non-zero exit fails THAT item
// (the caller counts it and continues to the next input) and exit(0) succeeds.
// Non-exit panics are re-raised unchanged: a genuine crash must stay loud.
func runBatchItemEntrypoint(rt *runtime.ModuleRuntime, params moduleExecParams) error {
return recoverBatchItemExit(func() error {
return executeModuleEntrypoint(rt, params)
})
}

// recoverBatchItemExit runs one batch item and maps the exit() sentinel panic
// onto that item's error result. Split out from runBatchItemEntrypoint so each
// branch is reachable from a unit test without standing up a module runtime.
func recoverBatchItemExit(run func() error) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
ec, ok := r.(*eval.EvalExitCode)
if !ok {
panic(r) // re-panic: not an exit(), so it is a real crash
}
if ec.Code != 0 {
err = fmt.Errorf("program called exit(%d)", ec.Code)
return
}
err = nil // exit(0) — the item finished successfully
}()
return run()
}

// debugLogLevel is the minimum severity level to print. Set by --log-level flag.
Expand Down
Loading