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
50 changes: 50 additions & 0 deletions cmd/rbgo/closed_argv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

//go:build rbgo_closed

package main

import (
"os"

"github.com/go-embedded-ruby/ruby/internal/vm"
)

// seedProcessArgs gives the embedded program the arguments this process was
// started with. A closed-world binary IS the program — there is no interpreter
// command line in front of it — so there is nothing to parse and nothing to
// split: every argument belongs to the program, exactly as it would for a C
// program, and $0 is the binary that was invoked.
//
// This is deliberately NOT ruby.c's proc_options. `./game -W0 level.dat` passes
// "-W0" to the game; a closed binary that swallowed it as an interpreter switch
// would be lying about whose command line it is.
//
// It is shared by the native and the wasm closed mains, and it reads os.Args on
// both, so no BUILD TAG decides whether a closed binary can take arguments —
// only the host does, which is the honest answer and not a tag-selected one.
//
// What each host puts there was measured, not assumed:
//
// - native: the process's argv, as usual.
// - js/wasm under node: Go's own lib/wasm/wasm_exec_node.js glue passes
// process.argv.slice(2) through, so `node wasm_exec_node.js prog.wasm a b`
// arrives as os.Args == {"prog.wasm", "a", "b"} and ARGV == ["a", "b"].
// Verified end to end by TestClosedWasmBuildIntegration.
// - js/wasm in a browser: there is no process argv at all; os.Args carries only
// "js", so ARGV is empty. Nothing here special-cases that.
// - wasip1: os.Args comes from the WASI args_get call. Measured with the OPEN
// CLI built for GOOS=wasip1 and run under node's node:wasi (preview1): the
// whole command line works there, ARGV included. A wasip1 CLOSED binary is not
// reachable through `rbgo build`, whose only wasm target is GOOS=js, so that
// exact combination is the one thing here that could not be run.
func seedProcessArgs(machine *vm.VM) {
if len(os.Args) > 0 {
machine.SetScriptName(os.Args[0])
machine.SetARGV(os.Args[1:])
return
}
machine.SetARGV(nil)
}
33 changes: 33 additions & 0 deletions cmd/rbgo/closed_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,39 @@ func TestClosedBuildIntegration(t *testing.T) {
t.Errorf("closed binary (%d) is not smaller than open (%d)", cs, os_)
}

// A standalone binary that cannot take arguments is not a usable program
// (#708). Its arguments are its OWN: there is no interpreter command line in
// front of them, so "-W0" here is a datum and not a switch, and the exit
// status proves the program saw all three.
//
// The program deliberately carries a string literal. On origin/main
// (87e418bc) `rbgo build --closed` fails outright for a program whose frozen
// bytecode holds NO object value — `p ARGV` is enough — because
// aot.FreezeISeq emits the internal/object import unconditionally and the
// nested go build then rejects it as unused. That is a separate defect, in
// internal/aot rather than here; this test must not depend on it.
argvRb := filepath.Join(dir, "argv.rb")
argvProg := "puts \"argv=#{ARGV.inspect} zero=#{$0.empty? ? \"(empty)\" : \"(set)\"}\"\n" +
"exit ARGV.length\n"
if err := os.WriteFile(argvRb, []byte(argvProg), 0o644); err != nil {
t.Fatal(err)
}
argvBin := filepath.Join(dir, "argv_closed")
rbgoBuild(t, root, "-o", argvBin, "--closed", argvRb)

argvCmd := exec.Command(argvBin, "-W0", "alpha", "beta")
argvOut, argvErr := argvCmd.CombinedOutput()
// Measured: three arguments, so `exit 3`.
if code := argvCmd.ProcessState.ExitCode(); code != 3 {
t.Errorf("closed binary exit status = %d, want 3 (it saw %d arguments); err=%v out=%s",
code, 3, argvErr, argvOut)
}
// $0 was the EMPTY string in a closed binary before this: nothing set it.
const wantArgv = "argv=[\"-W0\", \"alpha\", \"beta\"] zero=(set)\n"
if string(argvOut) != wantArgv {
t.Errorf("closed binary with arguments printed %q, want %q", argvOut, wantArgv)
}

// eval in a closed binary raises NotImplementedError (front-end dropped).
ev := filepath.Join(dir, "ev.rb")
if err := os.WriteFile(ev, []byte("puts eval(\"1 + 1\")\n"), 0o644); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions cmd/rbgo/closed_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import (
// runtime stays alive for browser callbacks.
func main() {
machine := vm.New(os.Stdout)
// The program's own arguments: a standalone binary that cannot take arguments
// is not a usable program (#708). See seedProcessArgs in closed_argv.go.
seedProcessArgs(machine)
_, err := machine.Run(embeddedProgram())
finish(machine, err) // never returns; see exit_status.go
}
3 changes: 3 additions & 0 deletions cmd/rbgo/closed_main_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import (
// the registered js.Func callbacks remain live (this mirrors cmd/wasm/main.go).
func main() {
machine := vm.New(os.Stdout)
// The program's own arguments, from whatever the wasm host supplied (#708).
// See seedProcessArgs in closed_argv.go.
seedProcessArgs(machine)
if _, err := machine.Run(embeddedProgram()); err != nil {
finish(machine, err) // never returns; see exit_status.go
}
Expand Down
131 changes: 96 additions & 35 deletions cmd/rbgo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,23 @@
// generated main in closed_main.go, which runs a single embedded program with no
// front-end linked — so this CLI is excluded from that build.
//
// rbgo run <file.rb> compile in memory and interpret
// rbgo run -e "<code>" run a one-liner
// rbgo <file.rb> shorthand for `rbgo run`
// rbgo build [-o out] <file.rb> AOT-compile the program's lowerable methods to
// native Go and link a specialised binary (see
// internal/aot and docs/aot-compiler.md)
// rbgo [options] [--] <file.rb> [arguments...] interpret, ARGV = the arguments
// rbgo [options] -e "<code>" [arguments...] run a one-liner
// rbgo run ... the same, spelled explicitly
// rbgo build [-o out] <file.rb> AOT-compile the program's
// lowerable methods to native Go
// and link a specialised binary
// (see internal/aot and
// docs/aot-compiler.md)
//
// The options and the script/ARGV boundary follow ruby.c; options.go holds the
// rule and the citations.
//
// `repl` arrives in a later phase (plan-rbgo.md §17).
package main

import (
"errors"
"fmt"
"os"

Expand All @@ -28,50 +34,73 @@ import (
func main() {
args := os.Args[1:]
if len(args) == 0 {
usage()
// No arguments at all is a usage error. MRI would read the program from
// stdin here; rbgo keeps the usage message because it is a multi-command
// front-end (run/build) and a bare `rbgo` silently waiting on a terminal
// would be worse. `rbgo -` still reads stdin, as does `rbgo` with options
// but no script — see options.load.
usageError()
}

cmd := args[0]
switch cmd {
switch args[0] {
case "run":
if len(args) == 1 {
usageError()
}
runCmd(args[1:])
case "build":
buildCmd(args[1:])
case "-h", "--help", "help":
usage()
case "help":
help()
default:
// Shorthand: `rbgo file.rb`.
// Everything else is `ruby`'s own command line, including -h/--help, which
// parseOptions recognises. A script whose name happens to be "run",
// "build" or "help" needs a path ("./run"), which was already true.
runCmd(args)
}
}

func runCmd(args []string) {
var src, name string
switch {
case len(args) == 2 && args[0] == "-e":
src, name = args[1], "-e"
case len(args) == 1:
b, err := os.ReadFile(args[0])
if err != nil {
fatal("rbgo: %v", err)
o, err := parseOptions(args, os.Stderr)
if err != nil {
// MRI's own wording and exit status, not a usage summary (#708).
fatal("%s", err)
}
if o.help {
help()
}
src, name, fromFile, err := o.load()
if err != nil {
var le *loadError
if errors.As(err, &le) {
// MRI's own LoadError line for an unreadable script, not Go's
// *os.PathError wording.
fatal("%s", le)
}
src, name = string(b), args[0]
default:
usage()
fatal("rbgo: %v", err)
}

// finish never returns: it is MRI's ruby_run_node -> rb_ec_cleanup, which
// turns the terminal exception into this process's exit status (or death by
// signal) rather than always exiting 0 or 1. See exit_status.go.
finish(run(src, name))
finish(runProgram(src, name, fromFile, o))
}

// run compiles and interprets one program, returning the machine it ran on
// run compiles and interprets one program with an empty ARGV and the default
// $VERBOSE. It is the shape the tests and the embedded callers want; the CLI goes
// through runProgram, which also seeds what the command line asked for.
func run(src, name string) (*vm.VM, error) {
return runProgram(src, name, name != "-e", nil)
}

// runProgram compiles and interprets one program, returning the machine it ran on
// alongside the error, because the terminal exception has to be classified
// against that machine's class hierarchy (vm.ExitingSplit). The machine is
// non-nil whenever the program actually ran; a parse or compile failure returns
// nil, which finish handles.
func run(src, name string) (*vm.VM, error) {
//
// o may be nil, which means an empty ARGV and an untouched $VERBOSE.
func runProgram(src, name string, fromFile bool, o *options) (*vm.VM, error) {
prog, err := parser.Parse(src)
if err != nil {
return nil, err
Expand All @@ -85,21 +114,53 @@ func run(src, name string) (*vm.VM, error) {
// $stderr/STDERR writes, Kernel#abort's message — goes to stderr, so a
// redirected or piped stdout carries only the program's data (#667).
machine := vm.NewWithStderr(os.Stdout, os.Stderr)
if name == "-e" {
// A -e one-liner has no file on disk; record "-e" as the program name so
// backtraces label its frames "-e" the way MRI does (require_relative still
// resolves against the CWD, since there is no script directory).
machine.SetScriptName(name)
} else {
if fromFile {
machine.SetScriptPath(name) // so require/require_relative resolve relative to the script
} else {
// A -e one-liner and a stdin program have no file on disk; record the name
// ("-e" / "-") so backtraces label their frames the way MRI does
// (require_relative still resolves against the CWD, since there is no
// script directory).
machine.SetScriptName(name)
}
if o != nil {
// ruby_set_argv, after the interpreter exists and before the program runs.
machine.SetARGV(o.argv)
if o.warnSet {
// proc_W_option only assigns ruby_verbose when a -w/-W was given, so an
// untouched $VERBOSE stays unset here too rather than being written with
// the value it already reads as (globals.go verboseSlot).
machine.SetWarningLevel(o.warnLevel)
}
}
_, err = machine.Run(iseq)
return machine, err
}

func usage() {
fmt.Fprintln(os.Stderr, "usage: rbgo run <file.rb> | rbgo run -e \"<code>\" | rbgo <file.rb>")
fmt.Fprintln(os.Stderr, " rbgo build [-o out] <file.rb> AOT-compile methods and link a native binary")
// usageText is rbgo's own summary. MRI's -h describes switches rbgo does not
// have, so this is deliberately rbgo's interface and not a transcription.
const usageText = `usage: rbgo [options] [--] <file.rb> [arguments...] interpret a script; ARGV = [arguments...]
rbgo [options] -e "<code>" [arguments...] run a one-liner
rbgo - read the program from stdin
rbgo run ... the same, spelled explicitly
rbgo build [-o out] [--closed] <file.rb> AOT-compile methods and link a native binary

options: -w, -W, -W0 | -W1 | -W2, -W:<category> $VERBOSE: -W0 silent, -W1 default, -w/-W2 verbose
-e <code> may be repeated; then ARGV takes no script
-h, --help this message`

// help prints the usage summary as a successful answer to a question — stdout,
// exit 0, which is what `ruby -h` does and what a shell pipeline expects. It
// never returns.
func help() {
fmt.Fprintln(os.Stdout, usageText)
os.Exit(0)
}

// usageError prints the same summary as a complaint: stderr, exit 2. It never
// returns.
func usageError() {
fmt.Fprintln(os.Stderr, usageText)
os.Exit(2)
}

Expand Down
Loading
Loading