diff --git a/.gitignore b/.gitignore index bc9ce32b..68f91529 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ cov.out # Editor / OS noise .DS_Store +/rbgo.exe diff --git a/internal/vm/array_mutators_test.go b/internal/vm/array_mutators_test.go index 7bd279bf..164e1765 100644 --- a/internal/vm/array_mutators_test.go +++ b/internal/vm/array_mutators_test.go @@ -36,6 +36,15 @@ func TestArrayMutators(t *testing.T) { // reverse_each: block iterates in reverse; no block yields an Enumerator. {`r = []; [1, 2, 3].reverse_each { |x| r << x }; p r`, "[3, 2, 1]\n"}, {`p [[1, 2, 3].reverse_each.to_a, [1, 2, 3].reverse_each.map { |x| x * 2 }]`, "[[3, 2, 1], [6, 4, 2]]\n"}, + // flatten!: mutates in place, returning self when it flattened, nil when + // there was nothing to flatten (including a depth-0 / empty / flat array), + // and an optional depth limits the recursion. + {`a = [1, [2, [3, 4]], 5]; p [a.flatten!, a]`, "[[1, 2, 3, 4, 5], [1, 2, 3, 4, 5]]\n"}, + {`a = [1, 2, 3]; p [a.flatten!, a]`, "[nil, [1, 2, 3]]\n"}, + {`p [[].flatten!, [1, [2]].flatten!(0)]`, "[nil, nil]\n"}, + {`a = [1, [2, [3]]]; p [a.flatten!(1), a]`, "[[1, 2, [3]], [1, 2, [3]]]\n"}, + // flatten! detects a change even when the length is unchanged ([[1]] -> [1]). + {`a = [[1]]; p [a.flatten!, a]`, "[[1], [1]]\n"}, } for _, c := range cases { if got := eval(t, c.src); got != c.want { diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 871830c3..b90a8cff 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -405,6 +405,7 @@ func (vm *VM) bootstrap() { vm.registerDir() // Dir (reuses the Errno module set up by registerFile) vm.registerTmpdir() // Dir.tmpdir / Dir.mktmpdir (layers onto Dir; require "tmpdir") vm.registerProcess() // Process module — identity + clock_gettime + vm.registerSpawn() // IO.pipe / read_nonblock / select + Process.spawn/waitpid2 + Kernel.fork/exec vm.registerObjectSpace() // ObjectSpace module — finalizer API + reflective no-ops vm.registerOpenSSL() // OpenSSL (real digest/HMAC/random + PKI/TLS shell); needs StandardError vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError @@ -1990,6 +1991,19 @@ func (vm *VM) bootstrap() { } return &object.Array{Elems: flattenDepth(self.(*object.Array).Elems, depth)} }) + vm.cArray.define("flatten!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + depth := -1 + if len(args) > 0 { + depth = int(intArg(args[0])) + } + a := self.(*object.Array) + out, changed := flattenDepthChanged(a.Elems, depth) + if !changed { + return object.NilV + } + a.Elems = out + return self + }) vm.cArray.define("sum", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { acc := object.Value(object.Integer(0)) if len(args) > 0 { @@ -2346,6 +2360,15 @@ func (vm *VM) bootstrap() { } return object.Bool(args[0].Truthy()) }) + // nil =~ anything is always nil in MRI (NilClass#=~), so `value =~ /re/` + // short-circuits when value is nil — Puppet's useradd provider relies on this + // when building its command from possibly-absent properties. + vm.cNilClass.define("=~", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 1 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 1)", len(args)) + } + return object.NilV + }) vm.cArray.define("sort_by", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { if blk == nil { return enumFor(self, "sort_by") @@ -4457,6 +4480,25 @@ func flattenDepth(elems []object.Value, depth int) []object.Value { return out } +// flattenDepthChanged flattens like flattenDepth but also reports whether any +// nested array was actually expanded. flatten!/flatten share semantics, but the +// bang form must return nil when nothing changed, which length alone cannot +// detect (e.g. [[1]] flattens to [1] with the same length). +func flattenDepthChanged(elems []object.Value, depth int) ([]object.Value, bool) { + var out []object.Value + changed := false + for _, e := range elems { + if sub, ok := e.(*object.Array); ok && depth != 0 { + changed = true + inner, _ := flattenDepthChanged(sub.Elems, depth-1) + out = append(out, inner...) + } else { + out = append(out, e) + } + } + return out, changed +} + // joinArray renders an array as a string, recursively joining nested arrays. func joinArray(a *object.Array, sep string) string { var b strings.Builder diff --git a/internal/vm/file_open_test.go b/internal/vm/file_open_test.go index 16d7b955..b83148f5 100644 --- a/internal/vm/file_open_test.go +++ b/internal/vm/file_open_test.go @@ -52,6 +52,11 @@ func TestFileStreams(t *testing.T) { // Non-block open: write, then close flushes. {fmt.Sprintf(`f = File.open(%q, "w"); f.write("Z"); f.close; p File.read(%q)`, w, w), "\"Z\"\n"}, {fmt.Sprintf(`p File.open(%q, "w").class`, w), "File\n"}, + // File.open / readlines / foreach accept a Pathname (anything answering + // #to_path), matching MRI — Puppet's FileSystem layer passes Pathnames. + {fmt.Sprintf(`require "pathname"; p File.open(Pathname.new(%q)) { |io| io.read(3) }`, hello), "\"hel\"\n"}, + {fmt.Sprintf(`require "pathname"; p File.readlines(Pathname.new(%q))`, lines), "[\"one\\n\", \"two\\n\"]\n"}, + {fmt.Sprintf(`require "pathname"; r = []; File.foreach(Pathname.new(%q)) { |l| r << l.chomp }; p r`, pq), "[\"p\", \"q\"]\n"}, } for _, c := range cases { if got := eval(t, c.src); got != c.want { diff --git a/internal/vm/io.go b/internal/vm/io.go index 02e8879c..2bb8582e 100644 --- a/internal/vm/io.go +++ b/internal/vm/io.go @@ -24,6 +24,25 @@ type IOObj struct { label string // "STDOUT"/"STDERR"/"STDIN" for inspect path string // backing file path for a File stream (else "") writable bool // a File opened for writing — flush the buffer on flush/close + + // Pipe ends (IO.pipe) share a single byte buffer in *pipe. The write end + // appends; the read end drains from pipe.rpos. Because subprocess execution + // in this VM is synchronous (Process.spawn / Kernel.exec run to completion + // before returning), the buffer is fully populated by the time a reader + // drains it, so a buffered model faithfully reproduces the blocking-read and + // EOF behaviour Puppet's execute loop relies on. reopened tracks the IO a + // standard stream (STDOUT/STDERR) was rebound to via #reopen, so a forked + // block's writes (and Kernel.exec's captured output) land on the pipe. + pipe *pipeBuf + isWriteEnd bool + reopened *IOObj +} + +// pipeBuf is the shared byte channel behind an IO.pipe reader/writer pair. +type pipeBuf struct { + data []byte + rpos int + wClosed bool } func (o *IOObj) ToS() string { @@ -38,6 +57,19 @@ func (o *IOObj) Truthy() bool { return true } // writeBytes appends p to the stream (advancing the StringIO cursor, overwriting // then extending) and returns the byte count. func (o *IOObj) writeBytes(p []byte) int { + // A standard stream reopened onto another IO (Puppet's safe_posix_fork does + // STDOUT.reopen(pipe_writer)) forwards its writes to that target. Follow the + // chain iteratively with a cycle guard so a degenerate self/loop reopen + // (e.g. STDOUT.reopen($stdout)) cannot recurse without bound. + for cur, seen := o, map[*IOObj]bool{}; cur.reopened != nil && cur.reopened != cur && !seen[cur]; { + seen[cur] = true + cur = cur.reopened + o = cur + } + if o.pipe != nil && o.isWriteEnd { + o.pipe.data = append(o.pipe.data, p...) + return len(p) + } if o.isStr { if end := o.pos + len(p); end > len(o.buf) { o.buf = append(o.buf, make([]byte, end-len(o.buf))...) @@ -52,6 +84,21 @@ func (o *IOObj) writeBytes(p []byte) int { func (o *IOObj) writeStr(s string) int { return o.writeBytes([]byte(s)) } +// pipeRefresh snapshots a pipe reader's shared buffer into the IOObj's own +// buf/pos view so the existing StringIO read methods (read/gets/eof?) operate on +// the latest pipe contents. It is a no-op for non-pipe streams. +func (o *IOObj) pipeRefresh() { + if o.pipe != nil && !o.isWriteEnd { + o.buf = o.pipe.data + } +} + +// pipeWriterClosed reports whether the write end of a pipe reader has been +// closed (EOF) — used by read_nonblock and IO.select to model EOF. +func (o *IOObj) pipeWriterClosed() bool { + return o.pipe != nil && o.pipe.wClosed +} + // registerIO installs the IO class with the writing methods, the StringIO class // (read + write), and the standard streams as both globals ($stdout/$stderr/ // $stdin) and constants (STDOUT/STDERR/STDIN). Kernel#puts/print/p are routed @@ -100,7 +147,7 @@ func (vm *VM) registerIO() { cFile := vm.consts["File"].(*RClass) cFile.super = cIO // File < IO, inheriting the read+write protocol; is_a?(IO) holds cFile.smethods["open"] = &Method{name: "open", owner: cFile, native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { - o := openFileIO(cFile, strArg(args[0]), fileMode(args)) + o := openFileIO(cFile, pathArg(vm, args[0]), fileMode(args)) if blk != nil { defer ioFlushClose(o) return vm.callBlock(blk, []object.Value{o}) @@ -134,8 +181,8 @@ func (vm *VM) registerIO() { } return object.Integer(0) }) - cFile.smethods["readlines"] = &Method{name: "readlines", owner: cFile, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - o := openFileIO(cFile, strArg(args[0]), "r") + cFile.smethods["readlines"] = &Method{name: "readlines", owner: cFile, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + o := openFileIO(cFile, pathArg(vm, args[0]), "r") var lines []object.Value for v := ioGets(o, nil); v != object.NilV; v = ioGets(o, nil) { lines = append(lines, v) @@ -143,7 +190,7 @@ func (vm *VM) registerIO() { return &object.Array{Elems: lines} }} cFile.smethods["foreach"] = &Method{name: "foreach", owner: cFile, native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { - o := openFileIO(cFile, strArg(args[0]), "r") + o := openFileIO(cFile, pathArg(vm, args[0]), "r") for v := ioGets(o, nil); v != object.NilV; v = ioGets(o, nil) { vm.callBlock(blk, []object.Value{v}) } @@ -337,6 +384,9 @@ func defIOWrite(cls *RClass) { cls.define("close", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { o := self.(*IOObj) ioFlush(o) + if o.pipe != nil && o.isWriteEnd { + o.pipe.wClosed = true // signal EOF to the read end + } o.closed = true return object.NilV }) @@ -362,10 +412,12 @@ func defStringIORead(cls *RClass) { }) cls.define("eof?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + o.pipeRefresh() return object.Bool(o.pos >= len(o.buf)) }) cls.define("eof", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + o.pipeRefresh() return object.Bool(o.pos >= len(o.buf)) }) cls.define("pos", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { @@ -411,6 +463,7 @@ func defStringIORead(cls *RClass) { }) cls.define("read", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + o.pipeRefresh() if len(args) > 0 && args[0] != object.NilV { n := int(toInt(args[0])) if o.pos >= len(o.buf) { @@ -485,6 +538,7 @@ func defStringIORead(cls *RClass) { // ioGets reads one line (up to and including the separator, default "\n") from a // StringIO, returning nil at end of input. func ioGets(o *IOObj, args []object.Value) object.Value { + o.pipeRefresh() if o.pos >= len(o.buf) { return object.NilV } diff --git a/internal/vm/nilclass_conv_test.go b/internal/vm/nilclass_conv_test.go index e21559d1..41c45b25 100644 --- a/internal/vm/nilclass_conv_test.go +++ b/internal/vm/nilclass_conv_test.go @@ -29,6 +29,9 @@ func TestNilClassConversions(t *testing.T) { {"xor_false", `p(nil ^ false)`, "false\n"}, {"xor_obj", `p(nil ^ 7)`, "true\n"}, {"xor_nil", `p(nil ^ nil)`, "false\n"}, + // nil =~ anything is nil (NilClass#=~), so `x =~ /re/` short-circuits on nil. + {"match_re", `p(nil =~ /x/)`, "nil\n"}, + {"match_str", `p(nil =~ "x")`, "nil\n"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -45,6 +48,7 @@ func TestNilClassBooleanArity(t *testing.T) { `nil.send(:&)`, `nil.send(:|)`, `nil.send(:^)`, + `nil.send(:=~)`, } { class, _ := evalErr(t, src) if class != "ArgumentError" { diff --git a/internal/vm/spawn.go b/internal/vm/spawn.go new file mode 100644 index 00000000..92aff791 --- /dev/null +++ b/internal/vm/spawn.go @@ -0,0 +1,331 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file gives the VM a synchronous, pure-Go process-execution model: IO.pipe +// (buffered reader/writer pair), Process.spawn / Process.waitpid2 / Process.setsid, +// Process::Status, and the Kernel#fork / Kernel#exec idiom that Puppet's +// Puppet::Util::Execution.execute path is built on (safe_posix_fork runs a block +// that reopens the standard streams and then calls Kernel.exec). +// +// There is no OS-level fork; instead Kernel.fork runs its block immediately, and +// the Kernel.exec inside that block runs the command to completion (capturing its +// combined output to wherever STDOUT/STDERR were reopened) and unwinds the block +// via a sentinel — modelling the fact that a real exec never returns. Because +// execution is synchronous, the pipe buffer is fully populated before the parent +// reads it, faithfully reproducing the blocking-read / EOF behaviour the Puppet +// loop depends on while staying CGO=0 and identical across every target OS. + +// childStatus records a finished child's exit code, keyed by the synthetic pid +// returned from spawn/fork so Process.waitpid2 can report it. +type childStatus struct { + pid int + code int +} + +// execSentinel is raised by Kernel.exec to unwind the enclosing Kernel.fork block +// (a real exec replaces the process and never returns to the block). It carries +// the captured exit code so fork can record the child's status. +type execSentinel struct{ code int } + +func (execSentinel) Error() string { return "exec sentinel" } + +// runCaptured runs a command, returning its combined stdout+stderr and exit code. +// A single string with shell metacharacters/whitespace goes through the system +// shell (MRI semantics); an explicit argv is run directly. It is defined per-OS +// in spawn_native.go / spawn_windows.go / spawn_wasm.go. + +// registerSpawn installs IO.pipe / read_nonblock / reopen / IO.select and the +// Process spawning entry points, plus Kernel#fork / Kernel#exec. +func (vm *VM) registerSpawn() { + cIO := vm.consts["IO"].(*RClass) + + cIO.smethods["pipe"] = &Method{name: "pipe", owner: cIO, native: func(vm *VM, _ object.Value, _ []object.Value, blk *Proc) object.Value { + buf := &pipeBuf{} + reader := &IOObj{cls: cIO, pipe: buf, label: "pipe-r"} + writer := &IOObj{cls: cIO, pipe: buf, isWriteEnd: true, label: "pipe-w"} + pair := &object.Array{Elems: []object.Value{reader, writer}} + if blk != nil { + // IO.pipe { |r, w| ... } yields the pair and closes both ends after. + defer func() { reader.closed, writer.closed, buf.wClosed = true, true, true }() + return vm.callBlock(blk, []object.Value{reader, writer}) + } + return pair + }} + + // read_nonblock / readpartial drain available pipe bytes; at EOF (write end + // closed, nothing buffered) they raise EOFError, as MRI does. + nonblock := func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + o.pipeRefresh() + if o.pos >= len(o.buf) { + if o.pipeWriterClosed() { + raise("EOFError", "end of file reached") + } + raise("Errno::EAGAIN", "Resource temporarily unavailable - read would block") + } + n := len(o.buf) - o.pos + if len(args) > 0 { + if m := int(intArg(args[0])); m < n { + n = m + } + } + s := object.NewString(string(o.buf[o.pos : o.pos+n])) + o.pos += n + return s + } + cIO.define("read_nonblock", nonblock) + cIO.define("readpartial", nonblock) + + // reopen rebinds a standard stream onto another IO (Puppet's safe_posix_fork + // does STDOUT.reopen(pipe_writer)); subsequent writes forward to the target. + cIO.define("reopen", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + if target, ok := args[0].(*IOObj); ok { + o.reopened = target + } + return self + }) + + // IO.select reports readiness. A reader is ready when it has buffered bytes or + // its write end is closed (so a subsequent read returns EOF rather than + // blocking). Writers and exception sets are always reported ready, matching the + // non-blocking, fully-synchronous model. + cIO.smethods["select"] = &Method{name: "select", owner: cIO, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + readReady := &object.Array{} + if len(args) > 0 { + if rs, ok := args[0].(*object.Array); ok { + for _, v := range rs.Elems { + if o, ok := v.(*IOObj); ok { + o.pipeRefresh() + if o.pos < len(o.buf) || o.pipeWriterClosed() { + readReady.Elems = append(readReady.Elems, o) + } + } + } + } + } + writeReady := &object.Array{} + if len(args) > 1 { + if ws, ok := args[1].(*object.Array); ok { + writeReady.Elems = append(writeReady.Elems, ws.Elems...) + } + } + if len(readReady.Elems) == 0 && len(writeReady.Elems) == 0 { + return object.NilV + } + return &object.Array{Elems: []object.Value{readReady, writeReady, &object.Array{}}} + }} + + vm.registerProcessSpawn() + vm.registerKernelExec() +} + +// registerProcessSpawn adds spawn / waitpid2 / setsid / Status to the Process +// module (already created by registerProcess). +func (vm *VM) registerProcessSpawn() { + mod := vm.consts["Process"].(*RClass) + def := func(name string, fn NativeFn) { mod.smethods[name] = &Method{name: name, owner: mod, native: fn} } + + // WNOHANG / WUNTRACED are the wait flags Puppet passes to waitpid2; only their + // truthiness (non-blocking) matters to the synchronous model. + mod.consts["WNOHANG"] = object.Integer(1) + vm.consts["Process::WNOHANG"] = object.Integer(1) + mod.consts["WUNTRACED"] = object.Integer(2) + vm.consts["Process::WUNTRACED"] = object.Integer(2) + + status := newClass("Status", vm.cObject) + status.name, status.named = "Process::Status", true + mod.consts["Status"] = status + vm.consts["Process::Status"] = status + status.define("exitstatus", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self.(*RObject).ivars["@exitstatus"] + }) + status.define("success?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self.(*RObject).ivars["@exitstatus"] == object.Integer(0)) + }) + status.define("pid", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self.(*RObject).ivars["@pid"] + }) + + // spawn(env?, command..., opts?) runs the command synchronously, writing its + // combined output to the :out / :err redirection targets, and returns a pid. + def("spawn", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + cmd, opts := parseSpawnArgs(args) + out, code := runCaptured(cmd) + writeSpawnOutput(opts, out) + return object.Integer(vm.recordChild(code)) + }) + + def("waitpid2", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + pid := int(intArg(args[0])) + st, ok := vm.reapChild(pid) + if !ok { + // WNOHANG (a non-zero flag) on an unknown/already-reaped child means + // "not ready / no child" — nil, as MRI returns. + if len(args) > 1 && intArg(args[1]) != 0 { + return object.NilV + } + raise("Errno::ECHILD", "No child processes") + } + so := &RObject{class: vm.consts["Process::Status"].(*RClass), ivars: map[string]object.Value{}} + so.ivars["@exitstatus"] = object.Integer(st.code) + so.ivars["@pid"] = object.Integer(st.pid) + return &object.Array{Elems: []object.Value{object.Integer(pid), so}} + }) + + def("waitpid", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + pid := int(intArg(args[0])) + if _, ok := vm.reapChild(pid); !ok { + if len(args) > 1 && intArg(args[1]) != 0 { + return object.NilV + } + raise("Errno::ECHILD", "No child processes") + } + return object.Integer(pid) + }) + + // setsid has no meaning without a real session, but Puppet calls it inside the + // forked block; return a pid so the call succeeds. + def("setsid", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Integer(processGID()) + }) +} + +// registerKernelExec installs Kernel#fork and Kernel#exec on Object so they are +// available to the main program and to library code (Puppet's safe_posix_fork). +func (vm *VM) registerKernelExec() { + def := func(name string, fn NativeFn) { + vm.cObject.methods[name] = &Method{name: name, owner: vm.cObject, native: fn} + } + + // fork runs its block in the current process (no OS fork). Kernel.exec inside + // the block unwinds it via execSentinel, carrying the child's exit code; fork + // records the child status and returns a synthetic pid. A block that returns + // normally (no exec) is treated as a child that exited 0. + def("fork", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("NotImplementedError", "fork without a block is not supported (no OS-level fork)") + } + code := vm.runForkBlock(blk) + return object.Integer(vm.recordChild(code)) + }) + + // exec runs the command to completion (capturing combined output to the + // current $stdout) and raises execSentinel so the enclosing fork block unwinds. + // At top level (no enclosing fork) it terminates the program with the child's + // exit code, as a real exec replaces the process. + def("exec", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + cmd := spawnCommand(args) + out, code := runCaptured(cmd) + vm.curStdout().writeStr(out) + panic(execSentinel{code: code}) + }) +} + +// runForkBlock calls blk, catching the execSentinel that Kernel.exec raises and +// returning the captured exit code; a normal return means exit code 0. +// +// A real fork runs the block in a separate process, so any standard-stream +// redirection it performs (Puppet's safe_posix_fork does STDOUT.reopen(pipe)) +// is private to the child. Without an OS fork we run the block in-process, so we +// snapshot and restore the STDOUT/STDERR reopen state around it; otherwise the +// redirection would leak into the parent and swallow its later output. +func (vm *VM) runForkBlock(blk *Proc) (code int) { + stdout, stderr := vm.curStdout(), vm.curStderr() + savedOut, savedErr := stdout.reopened, stderr.reopened + defer func() { + stdout.reopened, stderr.reopened = savedOut, savedErr + if r := recover(); r != nil { + if s, ok := r.(execSentinel); ok { + code = s.code + return + } + panic(r) + } + }() + vm.callBlock(blk, nil) + return 0 +} + +// recordChild stores a finished child's exit code under a fresh synthetic pid and +// returns that pid. +func (vm *VM) recordChild(code int) int64 { + vm.childPidSeq++ + pid := 100000 + vm.childPidSeq + vm.children = append(vm.children, childStatus{pid: int(pid), code: code}) + return pid +} + +// reapChild removes and returns the recorded status for pid. +func (vm *VM) reapChild(pid int) (childStatus, bool) { + for i, c := range vm.children { + if c.pid == pid { + vm.children = append(vm.children[:i], vm.children[i+1:]...) + return c, true + } + } + return childStatus{}, false +} + +// spawnArgEnv splits a leading environment Hash and a trailing options Hash off a +// spawn/exec argument list, returning the bare command argv. +func parseSpawnArgs(args []object.Value) (cmd []string, opts *object.Hash) { + rest := args + if len(rest) > 0 { + if _, ok := rest[0].(*object.Hash); ok { + rest = rest[1:] // leading env Hash — ignored (custom_environment handled by caller) + } + } + if len(rest) > 0 { + if h, ok := rest[len(rest)-1].(*object.Hash); ok { + opts = h + rest = rest[:len(rest)-1] + } + } + for _, a := range rest { + cmd = append(cmd, a.ToS()) + } + return cmd, opts +} + +// spawnCommand reduces a Kernel.exec argument list (no options) to argv. +func spawnCommand(args []object.Value) []string { + cmd, _ := parseSpawnArgs(args) + return cmd +} + +// writeSpawnOutput sends captured output to the :out (and :err, when distinct) +// redirection IO targets named in a spawn options Hash. +func writeSpawnOutput(opts *object.Hash, out string) { + if opts == nil { + return + } + if v, ok := opts.Get(object.Symbol("out")); ok { + if o, ok := v.(*IOObj); ok { + o.writeStr(out) + } + } +} + +// shellish reports whether a single command string should be run through the +// system shell (it contains shell metacharacters or whitespace), as MRI decides +// for a one-string command. +func shellish(cmd []string) (string, bool) { + if len(cmd) != 1 { + return "", false + } + s := cmd[0] + if strings.ContainsAny(s, " \t\n*?{}[]<>()~&|^$;'\"\\`") { + return s, true + } + return s, false +} diff --git a/internal/vm/spawn_internal_test.go b/internal/vm/spawn_internal_test.go new file mode 100644 index 00000000..2ca3b00f --- /dev/null +++ b/internal/vm/spawn_internal_test.go @@ -0,0 +1,308 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !(js && wasm) + +package vm + +import ( + "bytes" + "os/exec" + "runtime" + "strings" + "testing" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-ruby-parser/parser" +) + +// runSpawn compiles and runs src with runCaptured replaced by a deterministic +// stub, so the process-execution paths are exercised identically on every OS +// (no real subprocess). It returns captured stdout; a runtime error is surfaced +// as a t.Fatal unless wantErr matches the panic's Ruby class. +func runSpawn(t *testing.T, src string, fake func([]string) (string, int)) string { + t.Helper() + orig := runCaptured + runCaptured = fake + defer func() { runCaptured = orig }() + + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + var buf bytes.Buffer + if _, err := New(&buf).Run(iseq); err != nil { + t.Fatalf("run: %v\nsrc: %s", err, src) + } + return buf.String() +} + +// echoFake records the commands it was asked to run and echoes a fixed output. +func TestSpawnPipeAndWaitpid(t *testing.T) { + var got []string + fake := func(cmd []string) (string, int) { + got = append(got, strings.Join(cmd, " ")) + return "hi from rbgo\n", 0 + } + src := `r, w = IO.pipe +pid = Process.spawn("/bin/echo hi from rbgo", :out => w, :err => w) +w.close +out = r.read +r.close +_, st = Process.waitpid2(pid) +puts "OUT=#{out.inspect} EXIT=#{st.exitstatus} SUCCESS=#{st.success?} PID=#{st.pid == pid}"` + if out := runSpawn(t, src, fake); out != "OUT=\"hi from rbgo\\n\" EXIT=0 SUCCESS=true PID=true\n" { + t.Fatalf("got %q", out) + } + if len(got) != 1 || got[0] != "/bin/echo hi from rbgo" { + t.Fatalf("commands run: %v", got) + } +} + +// TestSpawnForkExec covers the Kernel.fork + Kernel.exec idiom (Puppet's +// execute path): the block's STDOUT redirection lands on the pipe and is +// restored afterwards so the parent's later writes reach real stdout. +func TestSpawnForkExec(t *testing.T) { + fake := func(cmd []string) (string, int) { return "child-out\n", 3 } + src := `r, w = IO.pipe +pid = Kernel.fork do + STDOUT.reopen(w) + Kernel.exec("/bin/echo", "child-out") +end +w.close +_, st = Process.waitpid2(pid) +puts "parent OUT=#{r.read.inspect} EXIT=#{st.exitstatus}"` + if out := runSpawn(t, src, fake); out != "parent OUT=\"child-out\\n\" EXIT=3\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnExecTopLevel covers Kernel.exec called outside any fork: it runs the +// command, writes its output to stdout, and unwinds the program. +func TestSpawnExecTopLevel(t *testing.T) { + fake := func(cmd []string) (string, int) { return "top\n", 0 } + // exec terminates the run; the trailing puts must NOT appear. + out := runSpawn(t, `Kernel.exec("/bin/echo top"); puts "after"`, fake) + if out != "top\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnForkNormalReturn covers a fork block that returns without exec: it is +// treated as a child that exited 0. +func TestSpawnForkNormalReturn(t *testing.T) { + fake := func(cmd []string) (string, int) { return "", 0 } + out := runSpawn(t, `pid = Kernel.fork { 41 + 1 } +_, st = Process.waitpid2(pid) +puts st.exitstatus`, fake) + if out != "0\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnForkBlockRaises covers runForkBlock re-propagating a non-exec panic: +// a Ruby exception raised inside the fork block escapes fork, as in MRI. +func TestSpawnForkBlockRaises(t *testing.T) { + fake := func(cmd []string) (string, int) { return "", 0 } + out := runSpawn(t, `begin + Kernel.fork { raise "boom" } +rescue => e + puts e.message +end`, fake) + if out != "boom\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnForkNoBlock covers fork without a block (unsupported): the program is +// expected to rescue the NotImplementedError it raises. +func TestSpawnForkNoBlock(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `begin; Kernel.fork; rescue => e; puts e.class.name; end`, fake) + if out != "NotImplementedError\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnReadNonblock covers read_nonblock / readpartial: draining buffered +// bytes, EAGAIN when nothing is buffered, and EOFError once the writer closes. +func TestSpawnReadNonblock(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `r, w = IO.pipe +w.write("abcd") +got = [r.read_nonblock(2), r.readpartial(10)] +begin; r.read_nonblock(1); rescue => e; got << e.class.name; end +w.close +begin; r.read_nonblock(1); rescue => e; got << e.class.name; end +p got`, fake) + if out != "[\"ab\", \"cd\", \"Errno::EAGAIN\", \"EOFError\"]\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnSelect covers IO.select readiness reporting and the all-empty nil +// result. +func TestSpawnSelect(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `r, w = IO.pipe +empty = IO.select([r], [], [], 0) +w.write("x") +ready, = IO.select([r], [w], []) +p [empty, ready.first.equal?(r)]`, fake) + if out != "[nil, true]\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnSelectEOFReady covers a reader becoming ready because its write end +// closed (EOF), even with no buffered bytes. +func TestSpawnSelectEOFReady(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `r, w = IO.pipe +w.close +ready, = IO.select([r]) +p ready.first.equal?(r)`, fake) + if out != "true\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnPipeBlockForm covers IO.pipe { |r, w| ... }. +func TestSpawnPipeBlockForm(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `v = IO.pipe { |r, w| w.write("z"); w.close; r.read } +p v`, fake) + if out != "\"z\"\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnGetsAndEof covers the pipe-reader path of gets/eof? (pipeRefresh). +func TestSpawnGetsAndEof(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `r, w = IO.pipe +w.write("a\nb\n") +w.close +lines = [r.gets, r.gets] +lines << r.eof? +p lines`, fake) + if out != "[\"a\\n\", \"b\\n\", true]\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnWaitpidUnknown covers waitpid2 / waitpid for an unknown pid: nil with +// a non-zero (WNOHANG) flag, ECHILD without. +func TestSpawnWaitpidUnknown(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `p Process.waitpid2(999, Process::WNOHANG) +p Process.waitpid(999, Process::WNOHANG) +[:waitpid2, :waitpid].each do |m| + begin; Process.send(m, 999); rescue => e; puts e.class.name; end +end`, fake) + if out != "nil\nnil\nErrno::ECHILD\nErrno::ECHILD\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnWaitpidSuccess covers waitpid (single-value form) reaping a child. +func TestSpawnWaitpidSuccess(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `pid = Process.spawn("x") +p Process.waitpid(pid) == pid`, fake) + if out != "true\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnSetsid covers Process.setsid (returns a pid-like integer). +func TestSpawnSetsid(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + out := runSpawn(t, `p Process.setsid.is_a?(Integer)`, fake) + if out != "true\n" { + t.Fatalf("got %q", out) + } +} + +// TestSpawnEnvAndOptsParsing covers parseSpawnArgs stripping a leading env Hash +// and a trailing options Hash, and writeSpawnOutput's no-opts / non-IO branches. +func TestSpawnEnvAndOptsParsing(t *testing.T) { + var got []string + fake := func(cmd []string) (string, int) { got = append(got, strings.Join(cmd, " ")); return "", 0 } + // Leading env hash + trailing opts hash whose :out is not an IO (ignored). + runSpawn(t, `Process.spawn({"A" => "1"}, "/bin/echo", "hi", {:out => 5})`, fake) + // No options hash at all. + runSpawn(t, `Process.spawn("/bin/echo", "bye")`, fake) + if len(got) != 2 || got[0] != "/bin/echo hi" || got[1] != "/bin/echo bye" { + t.Fatalf("commands: %v", got) + } +} + +// TestSpawnShellish covers the shell-vs-argv decision: a one-string command with +// metacharacters goes through the shell wrapper; a metachar-free argv does not. +func TestSpawnShellish(t *testing.T) { + for _, c := range []struct { + cmd []string + want bool + }{ + {[]string{"/bin/echo hi"}, true}, // whitespace + {[]string{"a|b"}, true}, // metachar + {[]string{"/bin/false"}, false}, // bare + {[]string{"a", "b"}, false}, // multi-element argv + } { + if _, sh := shellish(c.cmd); sh != c.want { + t.Errorf("shellish(%v) = %v, want %v", c.cmd, sh, c.want) + } + } +} + +// TestSpawnReopenCycle covers the cycle guard in writeBytes: a self-reopen must +// not recurse, and a normal reopen forwards writes to the target. +func TestSpawnReopenCycle(t *testing.T) { + fake := func([]string) (string, int) { return "", 0 } + // STDOUT.reopen($stdout) is a self/loop reopen; writing must still terminate + // (and land on real stdout). + out := runSpawn(t, `STDOUT.reopen($stdout); STDOUT.write("ok")`, fake) + if out != "ok" { + t.Fatalf("got %q", out) + } +} + +// TestExecSentinelError covers the execSentinel Error() string for completeness. +func TestExecSentinelError(t *testing.T) { + if (execSentinel{}).Error() != "exec sentinel" { + t.Fatal("execSentinel.Error mismatch") + } +} + +// TestExitCodeOf covers the exit-code extraction seam used by the native/windows +// runCaptured: success (nil), a real *exec.ExitError carrying a non-zero status, +// and a non-ExitError failure (mapped to 127, the shell's "not found" code). +func TestExitCodeOf(t *testing.T) { + if exitCodeOf(nil) != 0 { + t.Fatal("nil err should be 0") + } + if exitCodeOf(errString("boom")) != 127 { + t.Fatal("non-ExitError should be 127") + } + // Produce a genuine *exec.ExitError by running a command that exits non-zero. + var c *exec.Cmd + if runtime.GOOS == "windows" { + c = exec.Command("cmd", "/c", "exit 3") + } else { + c = exec.Command("sh", "-c", "exit 3") + } + err := c.Run() + if got := exitCodeOf(err); got != 3 { + t.Fatalf("exitCodeOf(ExitError) = %d, want 3", got) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/vm/spawn_native.go b/internal/vm/spawn_native.go new file mode 100644 index 00000000..5e88e7b6 --- /dev/null +++ b/internal/vm/spawn_native.go @@ -0,0 +1,43 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !windows && !(js && wasm) + +package vm + +import ( + "os/exec" +) + +// runCaptured runs cmd, returning its combined stdout+stderr and exit code. A +// one-string command with shell metacharacters/whitespace goes through /bin/sh +// (MRI semantics for a single string); an explicit argv runs directly. The +// command runner is a seam so tests can drive every branch without spawning real +// processes. +var runCaptured = func(cmd []string) (string, int) { + if len(cmd) == 0 { + return "", 127 + } + var c *exec.Cmd + if s, sh := shellish(cmd); sh { + c = exec.Command("/bin/sh", "-c", s) + } else { + c = exec.Command(cmd[0], cmd[1:]...) + } + out, err := c.CombinedOutput() + return string(out), exitCodeOf(err) +} + +// exitCodeOf extracts a process exit code from exec's error: 0 on success, the +// real status from an ExitError, and 127 (command not found, as a shell reports) +// otherwise. +func exitCodeOf(err error) int { + if err == nil { + return 0 + } + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + return 127 +} diff --git a/internal/vm/spawn_wasm.go b/internal/vm/spawn_wasm.go new file mode 100644 index 00000000..3f7fa566 --- /dev/null +++ b/internal/vm/spawn_wasm.go @@ -0,0 +1,14 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build js && wasm + +package vm + +// runCaptured has no meaning under js/wasm (no subprocesses), so spawning a +// command raises NotImplementedError there rather than silently succeeding. +var runCaptured = func(cmd []string) (string, int) { + raise("NotImplementedError", "subprocess execution is not supported on js/wasm") + return "", 127 +} diff --git a/internal/vm/spawn_windows.go b/internal/vm/spawn_windows.go new file mode 100644 index 00000000..4726d304 --- /dev/null +++ b/internal/vm/spawn_windows.go @@ -0,0 +1,39 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build windows + +package vm + +import ( + "os/exec" +) + +// runCaptured runs cmd on Windows, returning its combined output and exit code. A +// one-string command with metacharacters/whitespace goes through cmd.exe; an +// explicit argv runs directly. See spawn_native.go for the Unix variant. +var runCaptured = func(cmd []string) (string, int) { + if len(cmd) == 0 { + return "", 127 + } + var c *exec.Cmd + if s, sh := shellish(cmd); sh { + c = exec.Command("cmd", "/c", s) + } else { + c = exec.Command(cmd[0], cmd[1:]...) + } + out, err := c.CombinedOutput() + return string(out), exitCodeOf(err) +} + +// exitCodeOf extracts a process exit code from exec's error (see spawn_native.go). +func exitCodeOf(err error) int { + if err == nil { + return 0 + } + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + return 127 +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index c6188bd2..8a07c138 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -238,6 +238,12 @@ type VM struct { // (which only tracks required-file frames for __FILE__), every frame pushes // here so the two stacks stay aligned. GVL-guarded. frameFiles []string + + // children records finished synthetic child processes (Process.spawn / + // Kernel.fork), so Process.waitpid2 can report each one's exit status. + // childPidSeq assigns the next synthetic pid. GVL-guarded. + children []childStatus + childPidSeq int64 } // objectID returns the receiver's object_id / __id__. Immediate values get the @@ -526,6 +532,17 @@ func (vm *VM) Run(iseq *bytecode.ISeq) (result object.Value, err error) { if sig, ok := r.(throwSignal); ok { r = RubyError{Class: "UncaughtThrowError", Message: "uncaught throw " + sig.tag.Inspect()} } + // A top-level Kernel.exec (outside any fork) ran its command and is now + // "replacing" the process: terminate the run cleanly, like Kernel#exit, + // running at_exit handlers and returning without an error. + if _, ok := r.(execSentinel); ok { + vm.frameNames = vm.frameNames[:0] + vm.frameFiles = vm.frameFiles[:0] + vm.fileStack = vm.fileStack[:0] + vm.runAtExit() + result, err = object.NilV, nil + return + } rerr := r.(RubyError) // A SystemExit (Kernel#exit/abort) is a clean program termination, not a // crash: unwind to the top, run at_exit handlers, and return without an