Skip to content

Run() returns nil for exit/exit!/abort: an embedder cannot tell a finished program from a stopped one #773

Open

Description

@tannevaled

ruby.Run returns nil for a program that stopped itself, so an embedder cannot tell "finished" from "stopped".

Measured through the public API with a four-line Go embedder (not the rbgo CLI, which configures things Run does not):

abort "fatal: tenant failed"  ->  Run returned err=<nil>   (and "fatal: tenant failed" written to out)
exit 3                        ->  Run returned err=<nil>
exit!(42)                     ->  Run returned err=<nil>
raise "boom"                  ->  Run returned err=RuntimeError: boom

MRI exits 1 / 3 / 42 / 1 respectively. So the one case that is reported is the exception; the three that stop the program are indistinguishable from success, and abort's message lands in out looking like ordinary program output.

This is structural rather than an oversight, which is why it needs an API decision and not a patch: cmd/rbgo/exit_status.go recovers the status with machine.TerminalExit() / machine.ExitingSplit(err), but run.go does

_, err = vm.New(out).Run(iseq)

discarding the *vm.VM. Since internal/vm is unreachable from outside the module, no embedder can recover what the CLI recovers.

Shapes worth considering, in rough order of how little they commit to:

  1. A second entry point — RunStatus(src string, out io.Writer) (int, error) — leaving Run untouched.
  2. A sentinel error type (*ruby.ExitError{Code int}) returned by Run, so existing callers that only check err != nil start seeing exits as non-nil. That is a behaviour change for current callers and should be called out as one.
  3. Leave it and document it. Already done in docs: say what an embedded Run() can reach, and stop claiming nothing is shared between calls #770, which is why this issue is about the API and not the docs.

The documentation in #770 also pins the current behaviour in readme_embedding_contract_test.go, so whichever shape is chosen, that test is the thing to update — deliberately, rather than discovering the contract changed.

Found during the security audit tracked in #768.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions