From d513e8946f79cc10f575aeb9ce30ad7e06e9f459 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 20 Sep 2026 18:27:35 +0200 Subject: [PATCH 1/3] Process: resource limits, priorities, process groups and kill Implements the POSIX half of the Process module that rbgo had no trace of (`grep RLIMIT internal/vm` found nothing before this change): - Process::RLIMIT_*, Process::RLIM_INFINITY / RLIM_SAVED_MAX / RLIM_SAVED_CUR and Process::PRIO_PROCESS / PRIO_PGRP / PRIO_USER, taken from the host kernel headers through x/sys/unix rather than written out, since the numbers differ per OS (RLIMIT_NPROC is 6 on Linux and 7 on Darwin). - Process.getrlimit / setrlimit, with the full designator coercion. - Process.getpriority / setpriority. - Process.getpgid / getpgrp / setpgid / setpgrp / getsid. - Process.kill. Read for this (ruby/ruby v3_4_0): - process.c rlimit_resource_name2int, rlimit_resource_type, rlimit_resource_value, proc_getrlimit, proc_setrlimit - the uppercase-only name rule, the #to_str-before-#to_int order, the INFINITY / SAVED_MAX / SAVED_CUR limit names, and the fact that proc_setrlimit converts both limits before the resource. - process.c proc_getpgid / proc_setpgid / proc_getsid / proc_getpriority / proc_setpriority, including that all of them fail with rb_sys_fail(0) (no operation name in the message) while the rlimit pair pass one. - signal.c signm2signo and rb_f_kill - the leading '-' process-group form, the optional SIG prefix, "bad signal type ", and the return value of argc-1. - the ruby/spec expectations under core/process/{constants,getrlimit, setrlimit,kill,getpgid,getpriority}_spec.rb. Platform split follows the existing spawn_native/spawn_windows/spawn_wasm pattern: the kernel tables and the syscall wrappers are the only things behind a build tag, every coercion and error-shaping branch is in the shared process.go so the POSIX lanes cover all of it. One judgement call is documented in getpriorityFailed: getpriority(2) legitimately returns -1, which Darwin's libc boundary cannot distinguish from failure, so Go's wrapper reports a stale errno. MRI clears errno before the call and tests it after (proc_getpriority); Go cannot read errno, so a -1 result is believed unless the reported errno is one getpriority(2) documents. core/process on the pinned corpus: constants 2 -> 18 passing, getrlimit 2 -> 9, setrlimit 0 -> 30, kill 0 -> 4, getpgid 0 -> 3, getpriority 0 -> 4. Verified byte-for-byte against MRI 4.0.5 for every method and every error message. Co-Authored-By: Claude Opus 5 --- internal/vm/process.go | 373 ++++++++++++++++++++ internal/vm/process_limits_internal_test.go | 303 ++++++++++++++++ internal/vm/spawn_native.go | 97 +++++ internal/vm/spawn_wasm.go | 31 ++ internal/vm/spawn_windows.go | 31 ++ 5 files changed, 835 insertions(+) create mode 100644 internal/vm/process_limits_internal_test.go diff --git a/internal/vm/process.go b/internal/vm/process.go index 1d596190..92ccdb30 100644 --- a/internal/vm/process.go +++ b/internal/vm/process.go @@ -5,8 +5,15 @@ package vm import ( + "errors" + "math" + "math/big" "os" + "sort" + "strings" + "syscall" "time" + "unicode" "github.com/go-embedded-ruby/ruby/internal/object" ) @@ -88,6 +95,8 @@ func (vm *VM) registerProcess() { vm.consts["Process::CLOCK_REALTIME"] = object.IntValue(clockRealtime) mod.consts["CLOCK_MONOTONIC"] = object.IntValue(clockMonotonic) vm.consts["Process::CLOCK_MONOTONIC"] = object.IntValue(clockMonotonic) + + vm.registerProcessPosix(mod, def) } // Clock identifiers match the Linux/macOS values MRI exposes (CLOCK_REALTIME=0, @@ -135,3 +144,367 @@ var ( processEGID = os.Getegid processGroups = os.Getgroups ) + +// --------------------------------------------------------------------------- +// Resource limits, scheduling priority, process groups, sessions and signals +// +// The platform-bound half (the RLIMIT_ table and the syscall wrappers) lives in +// spawn_native.go; everything here is the portable argument peeling and error +// shaping, so it is exercised identically on every POSIX lane. +// +// Read for this: ruby/ruby v3_4_0 process.c — rlimit_resource_name2int, +// rlimit_resource_type, rlimit_resource_value, proc_getrlimit, proc_setrlimit, +// proc_getpgid/proc_setpgid/proc_getsid, proc_getpriority/proc_setpriority, +// and signal.c signm2signo / rb_f_kill. + +// registerProcessPosix installs the constants and methods that need a POSIX +// kernel. The constants are always defined from whatever the platform table +// holds (empty off POSIX, so Process::RLIMIT_CORE simply does not exist there); +// the methods only when the platform has them, mirroring MRI, where an absent +// getrlimit(2) turns Process.getrlimit into rb_f_notimplement and +// Process.respond_to?(:getrlimit) into false. +func (vm *VM) registerProcessPosix(mod *RClass, def func(string, NativeFn)) { + setConst := func(name string, v object.Value) { + mod.consts[name] = v + vm.consts["Process::"+name] = v + } + for name, res := range rlimitResources { + setConst("RLIMIT_"+name, object.IntValue(int64(res))) + } + for name, lim := range rlimitValues { + setConst("RLIM_"+name, rlimValue(lim)) + } + for name, which := range prioTargets { + setConst("PRIO_"+name, object.IntValue(int64(which))) + } + if procPosix { + vm.defineProcessLimits(def) + vm.defineProcessGroups(def) + vm.defineProcessKill(def) + } +} + +// defineProcessLimits installs Process.getrlimit / setrlimit / getpriority / +// setpriority. +func (vm *VM) defineProcessLimits(def func(string, NativeFn)) { + // getrlimit(resource) -> [cur, max]; proc_getrlimit coerces the resource + // through rlimit_resource_type and fails with rb_sys_fail("getrlimit"). + def("getrlimit", func(vm *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)) + } + cur, max, err := procGetrlimit(vm.rlimitResourceType(args[0])) + if err != nil { + sysFail(err, "getrlimit") + } + return object.NewArray(rlimValue(cur), rlimValue(max)) + }) + // setrlimit(resource, cur, max = cur) -> nil. proc_setrlimit converts BOTH + // limits before the resource (rlimit_resource_value runs first in the C), and + // an omitted or nil max repeats cur. + def("setrlimit", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 || len(args) > 3 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2..3)", len(args)) + } + cur := vm.rlimitResourceValue(args[1]) + max := cur + if len(args) == 3 && !object.IsNil(args[2]) { + max = vm.rlimitResourceValue(args[2]) + } + if err := procSetrlimit(vm.rlimitResourceType(args[0]), cur, max); err != nil { + sysFail(err, "setrlimit") + } + return object.NilV + }) + // getpriority(kind, id) -> int and setpriority(kind, id, priority) -> 0, + // straight NUM2INT coercions over getpriority(2)/setpriority(2). + def("getpriority", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + prio, err := procGetpriority(int(vm.procToInt(args[0])), int(vm.procToInt(args[1]))) + if err != nil && getpriorityFailed(prio, err) { + sysFail(err, "") + } + return object.IntValue(int64(prio)) + }) + def("setpriority", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 3 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 3)", len(args)) + } + err := procSetpriority(int(vm.procToInt(args[0])), int(vm.procToInt(args[1])), int(vm.procToInt(args[2]))) + if err != nil { + sysFail(err, "") + } + return object.IntValue(0) + }) +} + +// defineProcessGroups installs the process-group and session accessors. +// Process.setsid is NOT among them: it would detach the interpreter from its +// controlling terminal, which is not something a spec run may do to its own +// shell, so the existing accepted-and-ignored stand-in is kept. +func (vm *VM) defineProcessGroups(def func(string, NativeFn)) { + def("getpgid", func(vm *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)) + } + pgid, err := procGetpgid(int(vm.procToInt(args[0]))) + if err != nil { + sysFail(err, "") + } + return object.IntValue(int64(pgid)) + }) + // getpgrp takes no argument and is getpgid(0). + def("getpgrp", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + pgid, err := procGetpgid(0) + if err != nil { + sysFail(err, "") + } + return object.IntValue(int64(pgid)) + }) + // getsid(pid = 0) -> sid. + def("getsid", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + pid := 0 + if len(args) > 0 && !object.IsNil(args[0]) { + pid = int(vm.procToInt(args[0])) + } + sid, err := procGetsid(pid) + if err != nil { + sysFail(err, "") + } + return object.IntValue(int64(sid)) + }) + // setpgid(pid, pgid) -> 0, and setpgrp, which is setpgid(0, 0). + def("setpgid", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + if err := procSetpgid(int(vm.procToInt(args[0])), int(vm.procToInt(args[1]))); err != nil { + sysFail(err, "") + } + return object.IntValue(0) + }) + def("setpgrp", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if err := procSetpgid(0, 0); err != nil { + sysFail(err, "") + } + return object.IntValue(0) + }) +} + +// defineProcessKill installs Process.kill(signal, pid, ...) -> count. +// signal.c rb_f_kill: at least two arguments; a Fixnum signal is used as-is, +// anything else goes through signm2signo (which accepts a leading '-' to mean +// "signal the process group"); every remaining argument is a pid coerced with +// NUM2PIDT, and the return value is the number of pids signalled. +func (vm *VM) defineProcessKill(def func(string, NativeFn)) { + def("kill", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2+)", len(args)) + } + sig := vm.killSignalNumber(args[0]) + for _, pv := range args[1:] { + pid, send := int(vm.procToInt(pv)), sig + if sig < 0 { // killpg(pgid, sig) == kill(-pgid, sig) + pid, send = -pid, -sig + } + if err := procKill(pid, send); err != nil { + sysFail(err, "") + } + } + return object.IntValue(int64(len(args) - 1)) + }) +} + +// rlimValue wraps a rlim_t as a Ruby Integer. RLIM_INFINITY is ~0 on Linux, +// which does not fit in an int64, so an out-of-range limit promotes to a Bignum +// rather than wrapping negative (MRI's RLIM2NUM does the same through +// ULONG2NUM). +func rlimValue(lim uint64) object.Value { + if lim <= math.MaxInt64 { + return object.IntValue(int64(lim)) + } + return object.NormInt(new(big.Int).SetUint64(lim)) +} + +// rlimitResourceType coerces a resource designator to this host's resource +// number (process.c rlimit_resource_type). A Symbol, a String, or an object with +// #to_str names the resource; the name must be exactly the uppercase short form, +// since rlimit_resource_name2int matches case-insensitively and then rejects +// anything that is not all-uppercase. Anything else is NUM2INT. +func (vm *VM) rlimitResourceType(v object.Value) int { + name, named := vm.rlimitName(v) + if !named { + return int(vm.procToInt(v)) + } + if res, ok := rlimitResources[name]; ok { + return res + } + raise("ArgumentError", "invalid resource name: %s", name) + return 0 +} + +// rlimitResourceValue coerces a limit to a rlim_t (process.c +// rlimit_resource_value): the symbolic names INFINITY / SAVED_MAX / SAVED_CUR +// (matched exactly, by strcmp), otherwise NUM2RLIM. +func (vm *VM) rlimitResourceValue(v object.Value) uint64 { + name, named := vm.rlimitName(v) + if !named { + return uint64(vm.procToInt(v)) + } + if lim, ok := rlimitValues[name]; ok { + return lim + } + raise("ArgumentError", "invalid resource value: %s", name) + return 0 +} + +// rlimitName returns the name a resource/limit designator carries, and named = +// false when it carries none and the caller must fall through to NUM2INT. An +// Integer never reaches #to_str: process.c lists T_FIXNUM/T_BIGNUM as their own +// switch cases, ahead of the rb_check_string_type fallback. +func (vm *VM) rlimitName(v object.Value) (name string, named bool) { + switch s := v.(type) { + case object.Symbol: + return string(s), true + case *object.String: + return s.Str(), true + } + if _, isInt := object.BigOf(v); isInt { + return "", false + } + if vm.respondsToDynamic(v, "to_str") { + if s, ok := vm.send(v, "to_str", nil, nil).(*object.String); ok { + return s.Str(), true + } + } + return "", false +} + +// procToInt is NUM2INT/NUM2PIDT: an Integer passes through, nil raises the +// dedicated TypeError the C macro produces, and anything else must answer +// #to_int with an Integer (rb_to_int). +func (vm *VM) procToInt(v object.Value) int64 { + if i, ok := v.(object.Integer); ok { + return int64(i) + } + if object.IsNil(v) { + raise("TypeError", "no implicit conversion from nil to integer") + } + if !vm.respondsToDynamic(v, "to_int") { + raise("TypeError", "no implicit conversion of %s into Integer", classNameOf(v)) + } + r := vm.send(v, "to_int", nil, nil) + i, ok := r.(object.Integer) + if !ok { + raise("TypeError", "can't convert %s to Integer (%s#to_int gives %s)", + classNameOf(v), classNameOf(v), classNameOf(r)) + } + return int64(i) +} + +// errnoClasses inverts errnoNumbers so a failed syscall can name its Errno::Exxx +// class. Names are folded in sorted order so a platform where two names share a +// number (EAGAIN/EWOULDBLOCK on some kernels) always picks the same one. +var errnoClasses = func() map[int64]string { + names := make([]string, 0, len(errnoNumbers)) + for name := range errnoNumbers { + names = append(names, name) + } + sort.Strings(names) + m := make(map[int64]string, len(names)) + for _, name := range names { + m[errnoNumbers[name]] = name + } + return m +}() + +// sysFail raises the exception MRI's rb_sys_fail(op) would: the Errno::Exxx +// subclass for the failed call's errno, with the message " - " (or +// the bare strerror when op is empty, which is rb_sys_fail(0) — what rb_f_kill +// uses). An errno with no registered class falls back to SystemCallError, so an +// exotic failure is still a rescuable SystemCallError rather than a Go panic. +func sysFail(err error, op string) { + cls, msg := "SystemCallError", errnoMessage(err) + var eno syscall.Errno + if errors.As(err, &eno) { + if name, ok := errnoClasses[int64(eno)]; ok { + cls = "Errno::" + name + } + } + if op == "" { + raise(cls, "%s", msg) + } + raise(cls, "%s - %s", msg, op) +} + +// killSignalNumber resolves Process.kill's first argument to a signal number, +// following signal.c signm2signo: a Fixnum is taken as-is; a Symbol, String or +// #to_str object names a signal, optionally with a leading '-' (signal the +// process group) and optionally with the "SIG" prefix; anything else is +// "bad signal type ". An unknown name is reported with the SIG prefix +// restored, which is what MRI prints for both "FOO" and "SIGFOO". +func (vm *VM) killSignalNumber(v object.Value) int { + if i, ok := v.(object.Integer); ok { + return int(i) + } + name, named := vm.rlimitName(v) // Symbol / String / #to_str, as signm2signo takes + if !named { + raise("ArgumentError", "bad signal type %s", classNameOf(v)) + } + if strings.ContainsRune(name, 0) { + raise("ArgumentError", "signal name with null byte") + } + negative := strings.HasPrefix(name, "-") + if negative { + name = name[1:] + } + bare := strings.TrimPrefix(name, "SIG") + num, ok := signalNumbers[bare] + if !ok { + raise("ArgumentError", "unsupported signal 'SIG%s'", bare) + } + if negative { + return -num + } + return num +} + +// getpriorityFailed reports whether a getpriority(2) result is really a failure. +// The call legitimately returns -1 — a nice value of -1 — which at the Darwin +// libc boundary is indistinguishable from an error return: Go's wrapper decides +// by the return value and then reports whatever errno happened to hold, so +// Process.getpriority(PRIO_USER, uid) on a host whose user priority is -1 comes +// back with a stale, unrelated Errno. MRI has the same call and the same -1, and +// resolves it by clearing errno before the call and testing errno afterwards +// (process.c proc_getpriority); Go cannot read errno, so the next best rule is +// to believe a -1 result only when the reported errno is one getpriority(2) is +// documented to set: ESRCH for an unknown target, EINVAL for an unknown kind, +// EACCES/EPERM for one this process may not read. +func getpriorityFailed(prio int, err error) bool { + if prio != -1 { + return true + } + var eno syscall.Errno + if !errors.As(err, &eno) { + return false + } + switch eno { + case syscall.ESRCH, syscall.EINVAL, syscall.EACCES, syscall.EPERM: + return true + } + return false +} + +// errnoMessage renders an error the way C's strerror(3) does, which is the text +// MRI's rb_sys_fail embeds: Go's errno strings are the same table with a +// lowercased first letter ("no such process" against "No such process"), so the +// only difference to undo is that letter. +func errnoMessage(err error) string { + msg := []byte(err.Error()) + if len(msg) > 0 { + msg[0] = byte(unicode.ToUpper(rune(msg[0]))) + } + return string(msg) +} diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go new file mode 100644 index 00000000..f9a06783 --- /dev/null +++ b/internal/vm/process_limits_internal_test.go @@ -0,0 +1,303 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !windows && !wasm + +package vm + +import ( + "errors" + "strings" + "syscall" + "testing" +) + +// withGetrlimit swaps the getrlimit seam for the duration of fn, so the shared +// coercion and error-shaping paths can be driven without a kernel that fails on +// demand (and without a host whose real limits vary). +func withGetrlimit(t *testing.T, fake func(int) (uint64, uint64, error), fn func()) { + t.Helper() + orig := procGetrlimit + procGetrlimit = fake + defer func() { procGetrlimit = orig }() + fn() +} + +// TestProcessRlimitCoercion covers every way process.c's rlimit_resource_type +// and rlimit_resource_value accept (or reject) a designator: the constant, the +// Symbol and String short names, #to_str, #to_int, and each error. +func TestProcessRlimitCoercion(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + // Integer / Symbol / String resource, and the symbolic limit values. + {`p Process.getrlimit(Process::RLIMIT_CORE) == Process.getrlimit(:CORE)`, "true\n"}, + {`p Process.getrlimit("CORE") == Process.getrlimit(:CORE)`, "true\n"}, + {`p Process.setrlimit(:CORE, *Process.getrlimit(:CORE))`, "nil\n"}, + // #to_str wins over #to_int (rb_check_string_type runs first). + {`o = Object.new +def o.to_str; "CORE"; end +def o.to_int; raise "not reached"; end +p Process.getrlimit(o) == Process.getrlimit(:CORE)`, "true\n"}, + // #to_str that does not return a String falls through to #to_int. + {`o = Object.new +def o.to_str; nil; end +def o.to_int; Process::RLIMIT_CORE; end +p Process.getrlimit(o) == Process.getrlimit(:CORE)`, "true\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("%s\n got %q want %q", tc.src, got, tc.want) + } + } + + for _, tc := range []struct{ src, class, msg string }{ + {`Process.getrlimit(:FOO)`, "ArgumentError", "invalid resource name: FOO"}, + {`Process.getrlimit("core")`, "ArgumentError", "invalid resource name: core"}, + {`Process.getrlimit(nil)`, "TypeError", "no implicit conversion from nil to integer"}, + {`Process.getrlimit(Object.new)`, "TypeError", "no implicit conversion of Object into Integer"}, + {`o = Object.new; def o.to_int; nil; end; Process.getrlimit(o)`, + "TypeError", "can't convert Object to Integer (Object#to_int gives nil)"}, + {`Process.getrlimit`, "ArgumentError", "wrong number of arguments (given 0, expected 1)"}, + {`Process.setrlimit(:CORE)`, "ArgumentError", "wrong number of arguments (given 1, expected 2..3)"}, + {`Process.setrlimit(:CORE, 0, 0, 0)`, "ArgumentError", "wrong number of arguments (given 4, expected 2..3)"}, + {`Process.setrlimit(:CORE, :NOPE, 0)`, "ArgumentError", "invalid resource value: NOPE"}, + {`Process.getrlimit(-7)`, "Errno::EINVAL", "Invalid argument - getrlimit"}, + {`Process.setrlimit(-7, 0, 0)`, "Errno::EINVAL", "Invalid argument - setrlimit"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } + + // The remaining setrlimit forms — an omitted or nil hard limit (which + // repeats the soft one), a symbolic limit and a #to_int limit — go through + // the seam rather than the kernel: applying them for real would lower this + // process's own hard limit, which no later call could raise back, and every + // test after it would fail with EPERM. + var applied [][2]uint64 + origSet := procSetrlimit + procSetrlimit = func(_ int, cur, max uint64) error { + applied = append(applied, [2]uint64{cur, max}) + return nil + } + defer func() { procSetrlimit = origSet }() + for _, tc := range []struct { + src string + want [2]uint64 + }{ + {`Process.setrlimit(:CORE, 7)`, [2]uint64{7, 7}}, + {`Process.setrlimit(:CORE, 7, nil)`, [2]uint64{7, 7}}, + {`Process.setrlimit(:CORE, 7, 9)`, [2]uint64{7, 9}}, + {`Process.setrlimit(:CORE, :SAVED_CUR, "INFINITY")`, + [2]uint64{rlimitValues["SAVED_CUR"], rlimitValues["INFINITY"]}}, + {`o = Object.new; def o.to_int; 5; end; Process.setrlimit(:CORE, o, 5)`, [2]uint64{5, 5}}, + } { + applied = nil + eval(t, tc.src) + if len(applied) != 1 || applied[0] != tc.want { + t.Errorf("%s: setrlimit got %v want [%v]", tc.src, applied, tc.want) + } + } +} + +// TestProcessRlimitInfinityPromotes drives the seam with a limit that does not +// fit in an int64 — RLIM_INFINITY is ~0 on Linux — and asserts it comes back as +// a Bignum rather than wrapping negative. +func TestProcessRlimitInfinityPromotes(t *testing.T) { + withGetrlimit(t, func(int) (uint64, uint64, error) { return 1, ^uint64(0), nil }, func() { + if got := eval(t, `p Process.getrlimit(:CORE)`); got != "[1, 18446744073709551615]\n" { + t.Errorf("getrlimit with an out-of-int64 limit: got %q", got) + } + }) +} + +// TestProcessSysFailFallback: an errno with no registered Errno::Exxx class +// still surfaces as a rescuable SystemCallError rather than a Go panic. +func TestProcessSysFailFallback(t *testing.T) { + withGetrlimit(t, func(int) (uint64, uint64, error) { return 0, 0, errors.New("weird failure") }, func() { + class, msg := evalErr(t, `Process.getrlimit(:CORE)`) + if class != "SystemCallError" || msg != "Weird failure - getrlimit" { + t.Errorf("unregistered errno: got %s/%q", class, msg) + } + }) + // An errno that IS a syscall.Errno but is not one of the registered names + // takes the same fallback (the errno lookup misses). + withGetrlimit(t, func(int) (uint64, uint64, error) { return 0, 0, syscall.Errno(0) }, func() { + if class, _ := evalErr(t, `Process.getrlimit(:CORE)`); class != "SystemCallError" { + t.Errorf("errno 0: got class %s want SystemCallError", class) + } + }) +} + +// TestProcessGroupsAndSessions covers the process-group and session accessors, +// their arities, and their failure path. +func TestProcessGroupsAndSessions(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + {`p Process.getpgrp == Process.getpgid(0)`, "true\n"}, + {`p Process.getsid == Process.getsid(0)`, "true\n"}, + {`p Process.getsid(nil).is_a?(Integer)`, "true\n"}, + {`p Process.setpgid(0, Process.getpgrp)`, "0\n"}, + {`p Process.setpgrp`, "0\n"}, + {`p Process.getpriority(Process::PRIO_PROCESS, 0).is_a?(Integer)`, "true\n"}, + {`p Process.getpriority(Process::PRIO_USER, Process.uid).is_a?(Integer)`, "true\n"}, + {`p Process.setpriority(Process::PRIO_PROCESS, 0, Process.getpriority(Process::PRIO_PROCESS, 0))`, "0\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("%s: got %q want %q", tc.src, got, tc.want) + } + } + for _, tc := range []struct{ src, class, msg string }{ + {`Process.getpgid`, "ArgumentError", "wrong number of arguments (given 0, expected 1)"}, + {`Process.setpgid(0)`, "ArgumentError", "wrong number of arguments (given 1, expected 2)"}, + {`Process.getpriority(0)`, "ArgumentError", "wrong number of arguments (given 1, expected 2)"}, + {`Process.setpriority(0, 0)`, "ArgumentError", "wrong number of arguments (given 2, expected 3)"}, + {`Process.getpgid(2 ** 30)`, "Errno::ESRCH", "No such process"}, + {`Process.getsid(2 ** 30)`, "Errno::ESRCH", "No such process"}, + {`Process.setpgid(2 ** 30, 0)`, "Errno::ESRCH", "No such process"}, + {`Process.getpriority(99, 0)`, "Errno::EINVAL", "Invalid argument"}, + {`Process.setpriority(99, 0, 0)`, "Errno::EINVAL", "Invalid argument"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } +} + +// TestProcessSetpgrpFails drives setpgrp's failure branch, which a healthy +// kernel never takes for the calling process. +func TestProcessSetpgrpFails(t *testing.T) { + orig := procSetpgid + procSetpgid = func(int, int) error { return syscall.EPERM } + defer func() { procSetpgid = orig }() + if class, msg := evalErr(t, `Process.setpgrp`); class != "Errno::EPERM" || msg != "Operation not permitted" { + t.Errorf("setpgrp failure: got %s/%q", class, msg) + } + if class, _ := evalErr(t, `Process.setpgid(0, 0)`); class != "Errno::EPERM" { + t.Errorf("setpgid failure: got class %s", class) + } +} + +// TestProcessGetpgrpFails drives getpgrp's failure branch the same way. +func TestProcessGetpgrpFails(t *testing.T) { + orig := procGetpgid + procGetpgid = func(int) (int, error) { return -1, syscall.EPERM } + defer func() { procGetpgid = orig }() + if class, _ := evalErr(t, `Process.getpgrp`); class != "Errno::EPERM" { + t.Errorf("getpgrp failure: got class %s", class) + } +} + +// TestGetpriorityAmbiguousMinusOne pins the rule that resolves getpriority(2)'s +// ambiguous -1 return (see getpriorityFailed): a documented errno is a real +// failure, anything else is a nice value of -1. +func TestGetpriorityAmbiguousMinusOne(t *testing.T) { + orig := procGetpriority + defer func() { procGetpriority = orig }() + + // A stale, undocumented errno alongside -1 is not a failure. + procGetpriority = func(int, int) (int, error) { return -1, syscall.ETIMEDOUT } + if got := eval(t, `p Process.getpriority(Process::PRIO_USER, 0)`); got != "-1\n" { + t.Errorf("stale errno with -1: got %q want \"-1\\n\"", got) + } + // A non-errno error alongside -1 likewise. + procGetpriority = func(int, int) (int, error) { return -1, errors.New("not an errno") } + if got := eval(t, `p Process.getpriority(Process::PRIO_USER, 0)`); got != "-1\n" { + t.Errorf("non-errno with -1: got %q", got) + } + // A documented errno alongside -1 IS a failure. + procGetpriority = func(int, int) (int, error) { return -1, syscall.ESRCH } + if class, _ := evalErr(t, `Process.getpriority(Process::PRIO_USER, 0)`); class != "Errno::ESRCH" { + t.Errorf("ESRCH with -1: got class %s", class) + } + // Any other return value with an error is a failure whatever the errno. + procGetpriority = func(int, int) (int, error) { return -2, syscall.ETIMEDOUT } + if class, _ := evalErr(t, `Process.getpriority(Process::PRIO_USER, 0)`); class != "Errno::ETIMEDOUT" { + t.Errorf("errno with -2: got class %s", class) + } +} + +// TestProcessKill covers Process.kill's signal designators, its pid coercion, +// the process-group form, and every argument error. Only signal 0 is ever +// really delivered; the process-group and failure paths go through the seam so +// the test never signals anything. +func TestProcessKill(t *testing.T) { + if got := eval(t, `p Process.kill(0, Process.pid)`); got != "1\n" { + t.Errorf("kill(0, self): got %q", got) + } + if got := eval(t, `p Process.kill(0, Process.pid, Process.pid)`); got != "2\n" { + t.Errorf("kill(0, self, self): got %q", got) + } + + var calls [][2]int + orig := procKill + procKill = func(pid, sig int) error { + calls = append(calls, [2]int{pid, sig}) + return nil + } + defer func() { procKill = orig }() + + for _, tc := range []struct { + src string + want [2]int + }{ + {`Process.kill(:SIGTERM, 4242)`, [2]int{4242, 15}}, + {`Process.kill("SIGTERM", 4242)`, [2]int{4242, 15}}, + {`Process.kill("TERM", 4242)`, [2]int{4242, 15}}, + {`Process.kill(15, 4242)`, [2]int{4242, 15}}, + {`o = Object.new; def o.to_str; "TERM"; end; Process.kill(o, 4242)`, [2]int{4242, 15}}, + {`o = Object.new; def o.to_int; 4242; end; Process.kill("TERM", o)`, [2]int{4242, 15}}, + // A leading '-' signals the process group: killpg(pgid, sig). + {`Process.kill("-TERM", 4242)`, [2]int{-4242, 15}}, + {`Process.kill(:"-SIGTERM", 4242)`, [2]int{-4242, 15}}, + } { + calls = nil + eval(t, tc.src) + if len(calls) != 1 || calls[0] != tc.want { + t.Errorf("%s: kill called with %v want [%v]", tc.src, calls, tc.want) + } + } + + procKill = func(int, int) error { return syscall.ESRCH } + if class, msg := evalErr(t, `Process.kill("TERM", 4242)`); class != "Errno::ESRCH" || msg != "No such process" { + t.Errorf("kill failure: got %s/%q", class, msg) + } + + for _, tc := range []struct{ src, class, msg string }{ + {`Process.kill("TERM")`, "ArgumentError", "wrong number of arguments (given 1, expected 2+)"}, + {`Process.kill("FOO", 0)`, "ArgumentError", "unsupported signal 'SIGFOO'"}, + {`Process.kill("SIGFOO", 0)`, "ArgumentError", "unsupported signal 'SIGFOO'"}, + {`Process.kill("term", 0)`, "ArgumentError", "unsupported signal 'SIGterm'"}, + {`Process.kill("SIG", 0)`, "ArgumentError", "unsupported signal 'SIG'"}, + {`Process.kill("TER\0M", 0)`, "ArgumentError", "signal name with null byte"}, + {`Process.kill(Object.new, 0)`, "ArgumentError", "bad signal type Object"}, + {`Process.kill("TERM", nil)`, "TypeError", "no implicit conversion from nil to integer"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } +} + +// TestProcessLimitConstants: every RLIMIT_/RLIM_/PRIO_ name the platform table +// carries is reachable both as Process::NAME and through const_get, and the +// short name round-trips through getrlimit. +func TestProcessLimitConstants(t *testing.T) { + var b strings.Builder + for name := range rlimitResources { + b.WriteString("p Process.const_get(:RLIMIT_" + name + ").is_a?(Integer)\n") + b.WriteString("p Process.getrlimit(:" + name + ") == Process.getrlimit(Process::RLIMIT_" + name + ")\n") + } + for name := range rlimitValues { + b.WriteString("p Process::RLIM_" + name + ".is_a?(Integer)\n") + } + for name := range prioTargets { + b.WriteString("p Process::PRIO_" + name + ".is_a?(Integer)\n") + } + got := eval(t, b.String()) + want := strings.Repeat("true\n", 2*len(rlimitResources)+len(rlimitValues)+len(prioTargets)) + if got != want { + t.Errorf("constants: got %q want %q", got, want) + } +} diff --git a/internal/vm/spawn_native.go b/internal/vm/spawn_native.go index a5c75eab..cf04c873 100644 --- a/internal/vm/spawn_native.go +++ b/internal/vm/spawn_native.go @@ -8,6 +8,9 @@ package vm import ( "os/exec" + "syscall" + + "golang.org/x/sys/unix" ) // runCaptured runs cmd, returning its combined stdout+stderr and exit code. A @@ -66,3 +69,97 @@ func exitCodeOf(err error) int { } return 127 } + +// --------------------------------------------------------------------------- +// POSIX process seams +// +// Everything below is the thin, platform-bound half of the Process module: the +// resource-limit table this host's kernel uses, and one-line wrappers over the +// syscalls. All of the argument peeling, coercion and error shaping lives in the +// shared process.go, so the build-tagged variants (spawn_windows.go / +// spawn_wasm.go) stay empty and the POSIX lanes can cover every branch. +// +// Reference: ruby/ruby v3_4_0 process.c — rlimit_resource_name2int (the RLIMIT_ +// name table), proc_getrlimit / proc_setrlimit, proc_getpgid / proc_setpgid / +// proc_getsid, proc_getpriority / proc_setpriority; signal.c rb_f_kill. + +// procPosix reports that this build has the POSIX process surface (resource +// limits, process groups, sessions, priorities, kill). Process defines those +// methods only when it is true — on Windows MRI compiles them out entirely, and +// the ruby/spec expectation there is Process.respond_to?(:getrlimit) == false. +const procPosix = true + +// rlimitResources is the host's RLIMIT_ table, keyed by the bare name +// process.c's rlimit_resource_name2int accepts. The numbers differ per OS +// (RLIMIT_NPROC is 6 on Linux and 7 on Darwin), so they come from the kernel +// headers via x/sys/unix rather than being written out here. +var rlimitResources = map[string]int{ + "AS": unix.RLIMIT_AS, + "CORE": unix.RLIMIT_CORE, + "CPU": unix.RLIMIT_CPU, + "DATA": unix.RLIMIT_DATA, + "FSIZE": unix.RLIMIT_FSIZE, + "MEMLOCK": unix.RLIMIT_MEMLOCK, + "NOFILE": unix.RLIMIT_NOFILE, + "NPROC": unix.RLIMIT_NPROC, + "RSS": unix.RLIMIT_RSS, + "STACK": unix.RLIMIT_STACK, +} + +// rlimitValues is the symbolic limit table process.c's rlimit_resource_value +// consults (:INFINITY / :SAVED_MAX / :SAVED_CUR). POSIX only guarantees +// RLIM_INFINITY; the two saved-limit names are aliases of it on the platforms +// MRI ships them on, which is what Process::RLIM_SAVED_MAX reports on Darwin. +var rlimitValues = map[string]uint64{ + "INFINITY": uint64(unix.RLIM_INFINITY), + "SAVED_MAX": uint64(unix.RLIM_INFINITY), + "SAVED_CUR": uint64(unix.RLIM_INFINITY), +} + +// prioTargets is the PRIO_ table for getpriority(2)/setpriority(2). +var prioTargets = map[string]int{ + "PROCESS": unix.PRIO_PROCESS, + "PGRP": unix.PRIO_PGRP, + "USER": unix.PRIO_USER, +} + +// The seams themselves. Each is a package var so a whitebox test can drive the +// failure branch of the shared code without needing a kernel that fails. +var ( + procGetrlimit = func(res int) (cur, max uint64, err error) { + var rl unix.Rlimit + if err := unix.Getrlimit(res, &rl); err != nil { + return 0, 0, err + } + return uint64(rl.Cur), uint64(rl.Max), nil + } + procSetrlimit = func(res int, cur, max uint64) error { + rl := unix.Rlimit{Cur: cur, Max: max} + return unix.Setrlimit(res, &rl) + } + procGetpriority = unix.Getpriority + procSetpriority = unix.Setpriority + procGetpgid = unix.Getpgid + procSetpgid = unix.Setpgid + procGetsid = unix.Getsid + // procKill takes a plain int signal number so the shared caller never has to + // name syscall.Signal (which is spelt differently on the non-POSIX targets). + procKill = func(pid, sig int) error { return unix.Kill(pid, syscall.Signal(sig)) } + // procRusage reports this process's accumulated user and system CPU time in + // seconds, the two fields Process.times fills that Go's runtime does not + // expose (getrusage(RUSAGE_SELF)). + procRusage = func() (utime, stime float64) { + var ru unix.Rusage + if err := unix.Getrusage(unix.RUSAGE_SELF, &ru); err != nil { + return 0, 0 + } + return timevalSeconds(ru.Utime), timevalSeconds(ru.Stime) + } +) + +// timevalSeconds converts a struct timeval to fractional seconds. The field +// widths differ per architecture (int32 on 32-bit Linux, int64 elsewhere), so +// the conversion goes through int64 rather than naming a concrete type. +func timevalSeconds(tv unix.Timeval) float64 { + return float64(tv.Sec) + float64(tv.Usec)/1e6 +} diff --git a/internal/vm/spawn_wasm.go b/internal/vm/spawn_wasm.go index a147ed94..5ab37372 100644 --- a/internal/vm/spawn_wasm.go +++ b/internal/vm/spawn_wasm.go @@ -18,3 +18,34 @@ var systemCommand = func(cmd []string) (string, int, bool) { raise("NotImplementedError", "subprocess execution is not supported on wasm") return "", 127, false } + +// --------------------------------------------------------------------------- +// Non-POSIX process seams +// +// This target has no getrlimit/setrlimit, no process groups, no sessions and no +// kill(2), so the shared process.go defines none of the methods that need them +// (procPosix is false) and the seams below exist only to satisfy the compiler. +// MRI compiles the same methods out here, which is why ruby/spec expects +// Process.respond_to?(:getrlimit) to be false on Windows. +// +// Reference: ruby/ruby v3_4_0 process.c — the HAVE_SETRLIMIT / HAVE_SETPGID / +// HAVE_GETPRIORITY guards around proc_setrlimit, proc_setpgid and friends, +// each falling back to rb_f_notimplement. + +const procPosix = false + +var ( + rlimitResources = map[string]int{} + rlimitValues = map[string]uint64{} + prioTargets = map[string]int{} + + procGetrlimit func(res int) (cur, max uint64, err error) + procSetrlimit func(res int, cur, max uint64) error + procGetpriority func(which, who int) (int, error) + procSetpriority func(which, who, prio int) error + procGetpgid func(pid int) (int, error) + procSetpgid func(pid, pgid int) error + procGetsid func(pid int) (int, error) + procKill func(pid, sig int) error + procRusage func() (utime, stime float64) +) diff --git a/internal/vm/spawn_windows.go b/internal/vm/spawn_windows.go index d44b4b5b..b11e2127 100644 --- a/internal/vm/spawn_windows.go +++ b/internal/vm/spawn_windows.go @@ -58,3 +58,34 @@ func exitCodeOf(err error) int { } return 127 } + +// --------------------------------------------------------------------------- +// Non-POSIX process seams +// +// This target has no getrlimit/setrlimit, no process groups, no sessions and no +// kill(2), so the shared process.go defines none of the methods that need them +// (procPosix is false) and the seams below exist only to satisfy the compiler. +// MRI compiles the same methods out here, which is why ruby/spec expects +// Process.respond_to?(:getrlimit) to be false on Windows. +// +// Reference: ruby/ruby v3_4_0 process.c — the HAVE_SETRLIMIT / HAVE_SETPGID / +// HAVE_GETPRIORITY guards around proc_setrlimit, proc_setpgid and friends, +// each falling back to rb_f_notimplement. + +const procPosix = false + +var ( + rlimitResources = map[string]int{} + rlimitValues = map[string]uint64{} + prioTargets = map[string]int{} + + procGetrlimit func(res int) (cur, max uint64, err error) + procSetrlimit func(res int, cur, max uint64) error + procGetpriority func(which, who int) (int, error) + procSetpriority func(which, who, prio int) error + procGetpgid func(pid int) (int, error) + procSetpgid func(pid, pgid int) error + procGetsid func(pid int) (int, error) + procKill func(pid, sig int) error + procRusage func() (utime, stime float64) +) From a339dc17dd6bd8826949c758d644b49978a458cc Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 20 Sep 2026 18:56:44 +0200 Subject: [PATCH 2/3] Process: real spawn/exec argument handling, the wait family, times and argv0 Process.spawn scored pass=0 on 45 examples because it peeled its arguments by hand and ran everything through a combined capture. This replaces that with MRI's own argument model and adds the surface the rest of core/process was blocked on. Read for this (ruby/ruby v3_4_0 process.c unless noted): - rb_exec_getargs: the options Hash comes off the END first, the environment Hash off the FRONT afterwards - which is why `Process.spawn({})` is an ArgumentError about the COMMAND, and why check_hash rules String and Array out of the environment slot while letting anything with #to_hash in. - rb_check_argv: the [prog, argv0] command array - its FIRST element is the file to execute and its second is the name the child is told, it must hold exactly two elements, and every argument goes through StringValue then StringValueCStr (#to_str, then "string contains null byte"). - check_exec_env_i: "environment name contains a equal", the null-byte rejection on keys and values, and a nil value unsetting a variable. - rb_execarg_addopt / check_exec_options_i: the option keys, :pgroup's false/nil/true/0/pgid shapes with "negative process group ID : -1", and the two distinct errors - "wrong exec option" for a String key, "wrong exec option symbol: x" for an unknown Symbol. - check_exec_redirect: :out/:err as a String, a [name, mode] Array, an IO, an object with #to_io, a descriptor number, :close and [:child, :out]. - rb_f_spawn / rb_f_exec, and rb_spawn_internal's error path: a command that cannot be executed reports the errno its child's execve failed with, naming the command as WRITTEN, and leaves $? at the 127 that child exited with - already reaped, so Process.wait must not find it. - proc_wait / proc_wait2 / proc_waitall and the waitpid/waitpid2 aliases (one C function registered twice, so Method#== holds), rb_last_status_get, and rb_process_status_wait's pid -1 for "no children". - rb_proc_times and the rb_cProcessTms Struct; rb_clock_getres; proc_setmaxgroups, which is a process-local tunable, not the accepted-and-ignored no-op this had; proc_setproctitle; rb_proc_warmup; and the Process::UID / Process::GID / Process::Sys identity modules. - the ruby/spec expectations under core/process/. What is now real rather than modelled: the environment the child sees (including :unsetenv_others), its working directory, the program lookup against the CHILD's PATH rather than the parent's - os/exec would otherwise search the wrong one - the files :out and :err name, and the errno of a command that cannot be run. The execution itself stays synchronous, as the rest of this file has always been. core/process on the pinned corpus, this commit and the previous one together: pass 20 -> 165, fail+error 193 -> 40. Every method and every error message verified byte-for-byte against MRI 4.0.5. Full suite green on darwin; builds clean for GOOS=wasip1, windows and linux; internal/vm coverage unchanged at 99.9% with every new function at 100%. Co-Authored-By: Claude Opus 5 --- internal/vm/process.go | 152 ++++- internal/vm/process_limits_internal_test.go | 96 +++ internal/vm/spawn.go | 666 ++++++++++++++++++-- internal/vm/spawn_execarg_internal_test.go | 433 +++++++++++++ internal/vm/spawn_internal_test.go | 17 +- internal/vm/spawn_native.go | 22 + internal/vm/spawn_wasm.go | 7 + internal/vm/spawn_windows.go | 14 + 8 files changed, 1344 insertions(+), 63 deletions(-) create mode 100644 internal/vm/spawn_execarg_internal_test.go diff --git a/internal/vm/process.go b/internal/vm/process.go index 92ccdb30..eec018a4 100644 --- a/internal/vm/process.go +++ b/internal/vm/process.go @@ -68,14 +68,17 @@ func (vm *VM) registerProcess() { } return object.NewArrayFromSlice(elems) }) - // maxgroups is a platform tunable; reading it returns the conventional 16 cap - // and assigning it is accepted but ignored (the kernel limit is fixed), which - // matches MRI on the platforms we target. Puppet sets it inside a rescue. + // maxgroups is a process-local tunable MRI keeps in a static (process.c + // maxgroups / proc_setmaxgroups): it caps how many gids Process.groups= will + // accept, and reading it back returns whatever was last assigned. It starts at + // the conventional 16. Puppet sets it inside a rescue. + maxgroups := int64(processMaxGroups) def("maxgroups", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { - return object.IntValue(processMaxGroups) + return object.IntValue(maxgroups) }) - def("maxgroups=", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return args[0] // accepted-and-ignored; returns the assigned value + def("maxgroups=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + maxgroups = vm.procToInt(args[0]) + return args[0] }) def("clock_gettime", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { @@ -97,6 +100,7 @@ func (vm *VM) registerProcess() { vm.consts["Process::CLOCK_MONOTONIC"] = object.IntValue(clockMonotonic) vm.registerProcessPosix(mod, def) + vm.registerProcessResiduals(mod, def) } // Clock identifiers match the Linux/macOS values MRI exposes (CLOCK_REALTIME=0, @@ -429,6 +433,9 @@ func sysFail(err error, op string) { cls, msg := "SystemCallError", errnoMessage(err) var eno syscall.Errno if errors.As(err, &eno) { + // The errno's own text, not the wrapper's: an os.PathError would otherwise + // repeat the operation and the path that op already names. + msg = errnoMessage(eno) if name, ok := errnoClasses[int64(eno)]; ok { cls = "Errno::" + name } @@ -508,3 +515,136 @@ func errnoMessage(err error) string { } return string(msg) } + +// --------------------------------------------------------------------------- +// Process identity modules, CPU times, clock resolution and the small residuals +// +// Read for this: ruby/ruby v3_4_0 process.c — rb_proc_times and the +// rb_cProcessTms Struct, rb_clock_getres (the documented +// GETTIMEOFDAY_BASED_CLOCK_REALTIME / TIME_BASED_CLOCK_REALTIME / +// GETRUSAGE_BASED_CLOCK_PROCESS_CPUTIME_ID resolutions), proc_setproctitle and +// rb_proc_warmup, proc_setmaxgroups / proc_setgroups, and the +// Process::UID / Process::GID / Process::Sys module definitions at the foot of +// InitVM_process. + +// registerProcessResiduals installs the rest of the Process surface: the +// identity modules MRI defines beside Process itself, Process.times and its +// Process::Tms, Process.clock_getres, Process.argv0, and the small accessors. +func (vm *VM) registerProcessResiduals(mod *RClass, def func(string, NativeFn)) { + vm.registerProcessIdentityModules(mod) + vm.registerProcessTms(mod, def) + + // clock_getres(clock, unit = :float_second). The three documented symbolic + // clocks have fixed resolutions; the numeric clocks report the 1µs this + // host's clock_getres(2) does, which is what MRI answers on Linux and Darwin. + def("clock_getres", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + res := time.Microsecond + if sym, ok := args[0].(object.Symbol); ok && string(sym) == "TIME_BASED_CLOCK_REALTIME" { + res = time.Second + } + return clockGettimeUnit(res, args) + }) + + // argv0 is the name the main script was given, frozen, and the SAME object on + // every call — process.c keeps rb_progname in a single VALUE. + var argv0 object.Value + def("argv0", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if argv0 == nil { + argv0 = object.NewFrozenStringView(vm.scriptName) + } + return argv0 + }) + + // setproctitle records the title and answers with it. Rewriting the real + // process title needs the argv area the C runtime owns, which a Go program + // cannot reach, so `ps` keeps showing the original command — the return value + // and the fact that $0 is left alone are what this reproduces. + def("setproctitle", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + return args[0] + }) + + // warmup asks the VM to prepare for a steady-state workload; every part of it + // (a compaction, a heap preallocation) is implementation-specific, and MRI + // documents other implementations making it a no-op that answers true. + def("warmup", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(true) + }) +} + +// registerProcessIdentityModules installs Process::UID, Process::GID and +// Process::Sys — the three modules MRI defines beside Process for the same +// identity queries under their POSIX names. Only the queries are provided: the +// privilege switches they also carry would change this interpreter's own +// credentials, which a spec run must never do to the shell it was started from. +func (vm *VM) registerProcessIdentityModules(mod *RClass) { + sub := func(name string, methods map[string]func() int) *RClass { + m := newClass("Process::"+name, nil) + m.isModule = true + m.named = true + mod.consts[name] = m + vm.consts["Process::"+name] = m + for mname, fn := range methods { + get := fn + m.smethods[mname] = &Method{name: mname, owner: m, + native: func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(get())) + }} + } + return m + } + sub("UID", map[string]func() int{"rid": processUID, "eid": processEUID}) + sub("GID", map[string]func() int{"rid": processGID, "eid": processEGID}) + sub("Sys", map[string]func() int{ + "getuid": processUID, "geteuid": processEUID, + "getgid": processGID, "getegid": processEGID, + }) +} + +// registerProcessTms installs Process::Tms and Process.times. Tms is a Struct in +// MRI (rb_struct_define "utime", "stime", "cutime", "cstime"); here it is a +// plain class with the same four readers and writers, built so +// Process::Tms.new(a, b, c, d) and Process::Tms.new both work. +func (vm *VM) registerProcessTms(mod *RClass, def func(string, NativeFn)) { + tms := newClass("Tms", vm.cObject) + tms.name, tms.named = "Process::Tms", true + mod.consts["Tms"] = tms + vm.consts["Process::Tms"] = tms + fields := []string{"utime", "stime", "cutime", "cstime"} + tms.smethods["new"] = &Method{name: "new", owner: tms, native: func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := &RObject{class: self.(*RClass), ivars: map[string]object.Value{}} + for i, f := range fields { + o.ivars["@"+f] = object.NilV + if i < len(args) { + o.ivars["@"+f] = args[i] + } + } + return o + }} + for _, f := range fields { + name := f + tms.define(name, func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return self.(*RObject).ivars["@"+name] + }) + tms.define(name+"=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*RObject).ivars["@"+name] = args[0] + return args[0] + }) + } + + // times reports this process's accumulated CPU time. The child fields are + // zero: a child of this VM runs inside it, so its CPU time is already in the + // process's own utime/stime rather than credited separately. + def("times", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + utime, stime := procRusage() + o := &RObject{class: tms, ivars: map[string]object.Value{}} + o.ivars["@utime"], o.ivars["@stime"] = object.Float(utime), object.Float(stime) + o.ivars["@cutime"], o.ivars["@cstime"] = object.Float(0), object.Float(0) + return o + }) +} diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go index f9a06783..871c8f1c 100644 --- a/internal/vm/process_limits_internal_test.go +++ b/internal/vm/process_limits_internal_test.go @@ -301,3 +301,99 @@ func TestProcessLimitConstants(t *testing.T) { t.Errorf("constants: got %q want %q", got, want) } } + +// TestProcessResiduals covers Process::Tms and Process.times, clock_getres, +// argv0, setproctitle, warmup, maxgroups= and the Process::UID / GID / Sys +// identity modules. +func TestProcessResiduals(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + // Process::Tms: four readers and four writers, with and without arguments. + {`t = Process::Tms.new(1, 2, 3, 4); p [t.utime, t.stime, t.cutime, t.cstime]`, "[1, 2, 3, 4]\n"}, + {`t = Process::Tms.new; p [t.utime, t.stime, t.cutime, t.cstime]`, "[nil, nil, nil, nil]\n"}, + {`t = Process::Tms.new +t.utime, t.stime, t.cutime, t.cstime = 1, 2, 3, 4 +p [t.utime, t.stime, t.cutime, t.cstime]`, "[1, 2, 3, 4]\n"}, + {`p Process::Tms.name`, "\"Process::Tms\"\n"}, + // Process.times reports this process's own CPU time, with no child time. + {`t = Process.times +p [t.is_a?(Process::Tms), t.utime.is_a?(Float), t.utime >= 0.0, t.cutime, t.cstime]`, + "[true, true, true, 0.0, 0.0]\n"}, + {`u = Process.times.utime +1 until Process.times.utime > u +p Process.times.utime > u`, "true\n"}, + // clock_getres: the documented symbolic clocks and the numeric ones. + {`p Process.clock_getres(:GETTIMEOFDAY_BASED_CLOCK_REALTIME, :nanosecond)`, "1000\n"}, + {`p Process.clock_getres(:TIME_BASED_CLOCK_REALTIME, :nanosecond)`, "1000000000\n"}, + {`p Process.clock_getres(:GETRUSAGE_BASED_CLOCK_PROCESS_CPUTIME_ID, :nanosecond)`, "1000\n"}, + {`p Process.clock_getres(Process::CLOCK_REALTIME, :nanosecond)`, "1000\n"}, + // 1e-06 second, the same value MRI reports; the spelling differs only + // because this VM's Float#inspect drops the ".0" before the exponent + // (`p 0.000001` prints 1e-06 here and 1.0e-06 in MRI) — a core + // Float-formatting divergence, not a clock one. + {`p Process.clock_getres(Process::CLOCK_MONOTONIC)`, "1e-06\n"}, + // argv0 is frozen and is the same object every time. + {`p [Process.argv0.is_a?(String), Process.argv0.frozen?, Process.argv0.equal?(Process.argv0)]`, + "[true, true, true]\n"}, + {`p Process.setproctitle("a-title")`, "\"a-title\"\n"}, + {`p Process.warmup`, "true\n"}, + // maxgroups is a tunable that remembers what was assigned. + {`n = Process.maxgroups +Process.maxgroups = n - 1 +p Process.maxgroups == n - 1 +Process.maxgroups = n +p Process.maxgroups == n`, "true\ntrue\n"}, + // The identity modules answer with the same numbers Process does. + {`p [Process::UID.rid == Process.uid, Process::UID.eid == Process.euid]`, "[true, true]\n"}, + {`p [Process::GID.rid == Process.gid, Process::GID.eid == Process.egid]`, "[true, true]\n"}, + {`p [Process::Sys.getuid == Process.uid, Process::Sys.geteuid == Process.euid, + Process::Sys.getgid == Process.gid, Process::Sys.getegid == Process.egid]`, + "[true, true, true, true]\n"}, + {`p [Process::UID.name, Process::Sys.name]`, "[\"Process::UID\", \"Process::Sys\"]\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("%s\n got %q want %q", tc.src, got, tc.want) + } + } + for _, tc := range []struct{ src, class, msg string }{ + {`Process.clock_getres`, "ArgumentError", "wrong number of arguments (given 0, expected 1..2)"}, + {`Process.setproctitle`, "ArgumentError", "wrong number of arguments (given 0, expected 1)"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } +} + +// TestMergeEnv covers every branch of the environment merge: a name the base +// carries and the Hash overrides, one the Hash removes, one the Hash adds, and +// one the base carries untouched. +func TestMergeEnv(t *testing.T) { + got := mergeEnv([]string{"KEEP=1", "OVER=old", "DROP=1"}, + map[string]string{"OVER": "new", "ADD": "2"}, []string{"DROP"}) + want := []string{"KEEP=1", "OVER=new", "ADD=2"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("mergeEnv: got %v want %v", got, want) + } + if got := mergeEnv(nil, map[string]string{"B": "2", "A": "1"}, nil); strings.Join(got, " ") != "A=1 B=2" { + t.Errorf("mergeEnv(nil base): got %v want deterministic A=1 B=2", got) + } +} + +// TestKernelSystemEnvHash keeps Kernel#system's own (older, combined-capture) +// argument peeling exercised: a leading environment Hash and a trailing options +// Hash are both stripped off the command. +func TestKernelSystemEnvHash(t *testing.T) { + var got []string + fake := func(cmd []string) (string, int, bool) { + got = append(got, strings.Join(cmd, " ")) + return "", 0, true + } + orig := systemCommand + systemCommand = fake + defer func() { systemCommand = orig }() + eval(t, `system({"A" => "1"}, "/bin/echo", "hi", {:exception => false})`) + if len(got) != 1 || got[0] != "/bin/echo hi" { + t.Errorf("system with an env Hash: got %v", got) + } +} diff --git a/internal/vm/spawn.go b/internal/vm/spawn.go index 4c05ec86..97c0e23c 100644 --- a/internal/vm/spawn.go +++ b/internal/vm/spawn.go @@ -5,7 +5,11 @@ package vm import ( + "io" "math" + "os" + "path/filepath" + "sort" "strings" "github.com/go-embedded-ruby/ruby/internal/object" @@ -327,42 +331,15 @@ func (vm *VM) registerProcessSpawn() { 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. + // spawn([env,] command..., [options]) runs the command and returns the pid of + // the child whose status Process.wait reaps. process.c rb_f_spawn. 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.IntValue(vm.recordChild(code)) + return object.IntValue(vm.runExecArg(vm.parseExecArgs(args, true))) }) - 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.IntValue(int64(st.code)) - so.ivars["@pid"] = object.IntValue(int64(st.pid)) - return object.NewArray(object.IntValue(int64(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.IntValue(int64(pid)) - }) + // wait / wait2 / waitpid / waitpid2 / waitall / last_status, plus + // Process::Status.wait. + vm.registerProcessWait(mod, status) // setsid has no meaning without a real session, but Puppet calls it inside the // forked block; return a pid so the call succeeds. @@ -390,16 +367,29 @@ func (vm *VM) registerKernelExec() { return object.IntValue(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}) - }) + // exec runs the command to completion and raises execSentinel so the enclosing + // fork block unwinds (a real exec never returns to its caller). At top level — + // no enclosing fork — it terminates the program with the child's exit code, as + // a real exec replaces the process. Argument handling is rb_f_exec's, which is + // rb_execarg_new with accept_shell: a command that cannot be executed raises + // its errno here rather than in a child. + execFn := func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + e := vm.parseExecArgs(args, true) + if e.shell == "" { + path, class, msg, ok := spawnResolve(e.program(), e.env()) + if !ok { + raise(class, "%s", msg) + } + e.path = path + } + panic(execSentinel{code: vm.runPrepared(e)}) + } + def("exec", execFn) + // Process.exec is the same function (process.c registers rb_f_exec as both a + // global function and a Process module function), and must be callable with + // an explicit receiver — Kernel#exec alone is private. + procMod := vm.consts["Process"].(*RClass) + procMod.smethods["exec"] = &Method{name: "exec", owner: procMod, native: execFn} // system(env?, command..., opts?) runs the command synchronously, writing its // combined output to the current $stdout, and returns true when it exits 0, @@ -516,19 +506,6 @@ func spawnCommand(args []object.Value) []string { 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. @@ -594,3 +571,580 @@ type popenProc struct { pid int64 stdin []byte } + +// --------------------------------------------------------------------------- +// Process.spawn / Process.exec argument handling +// +// Read for this: ruby/ruby v3_4_0 process.c — rb_exec_getargs (peel the options +// Hash off the END, then the environment Hash off the FRONT), rb_check_argv +// (the [prog, argv0] command array, StringValue then StringValueCStr on every +// element), check_exec_env_i (the '=' and null-byte rules on an environment +// entry), rb_execarg_addopt and check_exec_options_i (the option keys and the +// two "wrong exec option" errors), check_exec_redirect (:out/:err targets), and +// rb_f_spawn / rb_f_exec. +// +// The execution model is the synchronous one this file is built on (see the +// header): the child runs to completion inside Process.spawn and its status is +// recorded for Process.wait to reap. What IS real is everything the parent can +// observe — the environment the child sees, its working directory, the files +// :out and :err name, and the errno a command that cannot be executed reports. + +// execArg is the peeled, validated form of a spawn/exec argument list: MRI's +// struct rb_execarg, reduced to the fields this VM can honour. +type execArg struct { + argv []string // the command as the child sees it; argv[0] is its own name + prog string // the [prog, argv0] command array's FIRST element: the file to + // execute, when it differs from the name the child is given + shell string // non-empty when the single-string form goes to the shell + path string // the program resolved against the child's PATH + dir string // :chdir + // The environment Hash's own pairs, kept apart from the interpreter's + // environment until the options have been read: :unsetenv_others decides + // whether the child inherits anything beyond them. + envOver map[string]string + envUnset []string + envGiven bool + unsetOthers bool + out, err *spawnSink +} + +// env returns the environment the child runs with: nil when it simply inherits +// the interpreter's, the Hash's pairs alone under :unsetenv_others, and the two +// merged otherwise. +func (e *execArg) env() []string { + if !e.envGiven && !e.unsetOthers { + return nil + } + if e.unsetOthers { + return mergeEnv(nil, e.envOver, nil) + } + return mergeEnv(os.Environ(), e.envOver, e.envUnset) +} + +// spawnSink is where one of the child's output streams goes. A nil sink means +// "the parent's own stream". +type spawnSink struct { + io *IOObj // :out => io / :out => fd + path string // :out => "name" / :out => ["name", mode] + append bool // the ["name", "a"] form +} + +// parseExecArgs performs rb_exec_getargs + rb_check_argv + the option and +// environment checks, returning the command ready to run. acceptShell is +// rb_execarg_new's flag: Process.spawn and Kernel#exec pass true, so a lone +// string with shell metacharacters goes to /bin/sh. +func (vm *VM) parseExecArgs(args []object.Value, acceptShell bool) *execArg { + rest := args + var opts, env *object.Hash + // The options Hash comes off the END first, then the environment Hash off the + // FRONT — the order rb_exec_getargs uses, and the reason `Process.spawn({})` + // is an ArgumentError about the COMMAND rather than a lone-Hash command. + if len(rest) > 0 { + if h, ok := vm.execHash(rest[len(rest)-1]); ok { + opts, rest = h, rest[:len(rest)-1] + } + } + if len(rest) > 0 { + if h, ok := vm.execHash(rest[0]); ok { + env, rest = h, rest[1:] + } + } + if len(rest) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + + e := &execArg{} + // rb_check_argv: a first argument that answers #to_ary is the [prog, argv0] + // command array, which must hold exactly two elements. + if ary, ok := vm.execAry(rest[0]); ok { + if len(ary.Elems) != 2 { + raise("ArgumentError", "wrong first argument") + } + // rb_check_argv: the FIRST element is the file to execute, the second is + // what the child is told its own name is (its argv[0]). + e.prog = vm.execString(ary.Elems[0]) + rest = append([]object.Value{ary.Elems[1]}, rest[1:]...) + } + for _, a := range rest { + e.argv = append(e.argv, vm.execString(a)) + } + // A single string with shell metacharacters is handed to the shell; an + // explicit argv (or a command array) never is. + if acceptShell && len(e.argv) == 1 && e.prog == "" { + if s, sh := shellish(e.argv); sh { + e.shell = s + } + } + vm.execEnv(e, env) + vm.execOptions(e, opts) + return e +} + +// program is the file a command actually executes: the command array's first +// element when one was given, otherwise argv[0]. +func (e *execArg) program() string { + if e.prog != "" { + return e.prog + } + return e.argv[0] +} + +// execHash reports whether v is the environment/options Hash slot's occupant. +// check_hash excludes String and Array outright and otherwise asks #to_hash, so +// a mock that answers #to_hash is accepted as the environment. +func (vm *VM) execHash(v object.Value) (*object.Hash, bool) { + if h, ok := v.(*object.Hash); ok { + return h, true + } + if _, isStr := v.(*object.String); isStr { + return nil, false + } + if _, isAry := v.(*object.Array); isAry { + return nil, false + } + if vm.respondsToDynamic(v, "to_hash") { + if h, ok := vm.send(v, "to_hash", nil, nil).(*object.Hash); ok { + return h, true + } + } + return nil, false +} + +// execAry is rb_check_array_type on the first command argument: an Array, or +// anything that answers #to_ary with one. +func (vm *VM) execAry(v object.Value) (*object.Array, bool) { + if a, ok := v.(*object.Array); ok { + return a, true + } + if vm.respondsToDynamic(v, "to_ary") { + if a, ok := vm.send(v, "to_ary", nil, nil).(*object.Array); ok { + return a, true + } + } + return nil, false +} + +// execString is StringValue followed by StringValueCStr: #to_str conversion, +// then the null-byte rejection every exec argument goes through. +func (vm *VM) execString(v object.Value) string { + s, ok := v.(*object.String) + if !ok { + if !vm.respondsToDynamic(v, "to_str") { + raise("TypeError", "no implicit conversion of %s into String", classNameOf(v)) + } + s, ok = vm.send(v, "to_str", nil, nil).(*object.String) + if !ok { + raise("TypeError", "can't convert %s to String", classNameOf(v)) + } + } + str := s.Str() + if strings.ContainsRune(str, 0) { + raise("ArgumentError", "string contains null byte") + } + return str +} + +// execEnv records a leading environment Hash's pairs on the execArg +// (check_exec_env_i): keys and values are coerced with #to_str, a key may carry +// neither '=' nor a null byte, and a nil value unsets the variable. +func (vm *VM) execEnv(e *execArg, h *object.Hash) { + e.envOver = map[string]string{} + if h == nil { + return + } + e.envGiven = true + for _, k := range h.Keys { + name := vm.execString(k) + if strings.ContainsRune(name, '=') { + raise("ArgumentError", "environment name contains a equal : %s", name) + } + v, _ := h.Get(k) + if object.IsNil(v) { + e.envUnset = append(e.envUnset, name) + continue + } + e.envOver[name] = vm.execString(v) + } +} + +// mergeEnv applies an environment Hash's overrides and removals to a base +// environment, preserving the base's order and appending new names. +func mergeEnv(base []string, over map[string]string, unset []string) []string { + drop := make(map[string]bool, len(unset)) + for _, n := range unset { + drop[n] = true + } + out := make([]string, 0, len(base)+len(over)) + seen := make(map[string]bool, len(base)) + for _, kv := range base { + name, _, _ := strings.Cut(kv, "=") + if drop[name] { + continue + } + seen[name] = true + if v, ok := over[name]; ok { + out = append(out, name+"="+v) + continue + } + out = append(out, kv) + } + names := make([]string, 0, len(over)) + for name := range over { + if !seen[name] { + names = append(names, name) + } + } + sort.Strings(names) // deterministic order for the names the base did not carry + for _, name := range names { + out = append(out, name+"="+over[name]) + } + return out +} + +// execOptions applies the trailing options Hash (rb_execarg_addopt): the option +// keys this VM honours, the two argument errors MRI raises for an unknown key, +// and the :out/:err redirections. +func (vm *VM) execOptions(e *execArg, h *object.Hash) { + if h == nil { + return + } + for _, k := range h.Keys { + v, _ := h.Get(k) + sym, ok := k.(object.Symbol) + if !ok { + // A Fixnum / IO / Array key is a file-descriptor redirection, which this + // VM does not model; anything else is MRI's bare "wrong exec option". + raise("ArgumentError", "wrong exec option") + } + switch string(sym) { + case "out": + e.out = vm.execRedirect(v) + case "err": + e.err = vm.execRedirect(v) + case "chdir": + e.dir = vm.execString(v) + case "pgroup": + vm.execPgroup(v) + case "unsetenv_others": + e.unsetOthers = v.Truthy() + case "umask", "close_others", "exception", "in", "new_pgroup", "uid", "gid", "rlimit_core", "rlimit_cpu", "rlimit_nofile": + // Accepted and ignored: these change the child's own state, which the + // synchronous model shares with the parent, so honouring them would + // change the interpreter rather than a child. + default: + raise("ArgumentError", "wrong exec option symbol: %s", string(sym)) + } + } +} + +// execPgroup validates the :pgroup option. The process group a synchronous child +// joins is the interpreter's own, so the value is only checked, never applied — +// but the checks are observable: false/nil mean "stay in this group", true and 0 +// mean "a new one", and anything else is a pid that may not be negative. +func (vm *VM) execPgroup(v object.Value) { + if !v.Truthy() || v == object.Bool(true) { + return + } + if pgid := vm.procToInt(v); pgid < 0 { + raise("ArgumentError", "negative process group ID : %d", pgid) + } +} + +// execRedirect resolves an :out / :err option to a sink (check_exec_redirect): +// a String is a file name opened for writing, a two-element [name, mode] Array +// names the mode, an IO (or an object with #to_io) is written directly, and a +// [:child, :out] pair or a file descriptor folds onto the other stream. +func (vm *VM) execRedirect(v object.Value) *spawnSink { + switch t := v.(type) { + case *object.String: + return &spawnSink{path: t.Str()} + case *IOObj: + return &spawnSink{io: t} + case object.Symbol: + // :close / :out / :err / :in — a fold onto another of the child's own + // streams, which the combined-capture model already merges. + return nil + case *object.Array: + return vm.execRedirectArray(t) + } + if vm.respondsToDynamic(v, "to_io") { + if o, ok := vm.send(v, "to_io", nil, nil).(*IOObj); ok { + return &spawnSink{io: o} + } + } + if _, isInt := v.(object.Integer); isInt { + return nil // a bare descriptor number: nothing this VM can reopen + } + raise("ArgumentError", "wrong exec redirect action") + return nil +} + +// execRedirectArray handles the Array forms of a redirection value: the +// [:child, fd] fold and the [path, mode, perm] open. +func (vm *VM) execRedirectArray(a *object.Array) *spawnSink { + if len(a.Elems) == 2 && a.Elems[0] == object.Symbol("child") { + return nil + } + if len(a.Elems) == 0 { + raise("ArgumentError", "wrong exec redirect action") + } + sink := &spawnSink{path: vm.execString(a.Elems[0])} + if len(a.Elems) > 1 { + if m, ok := a.Elems[1].(*object.String); ok { + sink.append = strings.HasPrefix(m.Str(), "a") + } + } + return sink +} + +// spawnResolve reports the errno a command in argv form would fail with before +// it ever runs. MRI learns this from the child, which reports the failed execve +// back through its error pipe; the message it raises names the command as +// written, not the resolved path. +// +// A program whose name contains a separator is used as given: a missing path is +// ENOENT, a directory or a file without an execute bit is EACCES. A bare name is +// looked up in the child's PATH and only an executable counts, so a +// non-executable file of the same name earlier in PATH does not shadow it — the +// lookup simply fails with ENOENT. +var spawnResolve = func(prog string, env []string) (path, class, msg string, ok bool) { + if strings.ContainsRune(prog, os.PathSeparator) { + return spawnResolveOne(prog, prog) + } + for _, dir := range filepath.SplitList(spawnEnvGet(env, "PATH")) { + if dir == "" { + dir = "." + } + if found, _, _, okOne := spawnResolveOne(filepath.Join(dir, prog), prog); okOne { + return found, "", "", true + } + } + return "", "Errno::ENOENT", "No such file or directory - " + prog, false +} + +// spawnResolveOne checks one candidate path, reporting the failure under the +// name the caller wrote. +func spawnResolveOne(path, prog string) (resolved, class, msg string, ok bool) { + st, err := os.Stat(path) + if err != nil { + return "", "Errno::ENOENT", "No such file or directory - " + prog, false + } + if st.IsDir() || st.Mode().Perm()&0o111 == 0 { + return "", "Errno::EACCES", "Permission denied - " + prog, false + } + return path, "", "", true +} + +// spawnEnvGet reads one variable out of an environment slice, falling back to +// the interpreter's own environment when the child inherits it. +func spawnEnvGet(env []string, name string) string { + if env == nil { + return os.Getenv(name) + } + for _, kv := range env { + if n, v, _ := strings.Cut(kv, "="); n == name { + return v + } + } + return "" +} + +// spawnReq is one prepared child for the platform runner. The command is either +// a shell line or an argv whose [0] is the name the child is told — which the +// [prog, argv0] command-array form makes differ from the file at path. +type spawnReq struct { + argv []string // argv[0] is the name the child is given, not the file run + shell string + path string // the file to execute, already resolved against the child's PATH + dir string + env []string // nil ⇒ inherit the interpreter's environment + stdout io.Writer + stderr io.Writer +} + +// runExecArg runs a prepared command to completion, delivers its output to the +// requested sinks, records the child's status and returns the synthetic pid +// Process.wait reaps. +func (vm *VM) runExecArg(e *execArg) int64 { + vm.checkChdir(e) + if e.shell == "" { + path, class, msg, ok := spawnResolve(e.program(), e.env()) + if !ok { + vm.spawnFailed(class, msg) + } + e.path = path + } + return vm.recordChild(vm.runPrepared(e)) +} + +// checkChdir rejects a :chdir directory that does not exist. MRI's child fails +// the chdir(2) before the exec and reports the errno back to the parent, so the +// caller sees Errno::ENOENT naming the directory rather than a child that +// silently failed to start. +func (vm *VM) checkChdir(e *execArg) { + if e.dir == "" { + return + } + if _, err := os.Stat(e.dir); err != nil { + vm.spawnFailed("Errno::ENOENT", "No such file or directory - "+e.dir) + } +} + +// spawnFailed reports a command that could not be started. By the time MRI's +// parent learns of it the child it forked has already exited 127, so $? carries +// that status even though spawn itself raises — but the child is ALREADY reaped +// (process.c rb_spawn_internal waits for it), so it must not be left for +// Process.wait to find. +func (vm *VM) spawnFailed(class, msg string) { + vm.childPidSeq++ + vm.globals["$?"] = vm.newProcessStatus(100000+vm.childPidSeq, 127) + raise(class, "%s", msg) +} + +// runPrepared hands one prepared command to the platform runner and delivers +// its two streams to their sinks, returning the child's exit code. +func (vm *VM) runPrepared(e *execArg) int { + var out, errOut strings.Builder + code := runSpawnProc(&spawnReq{argv: e.argv, shell: e.shell, path: e.path, + dir: e.dir, env: e.env(), stdout: &out, stderr: &errOut}) + vm.writeSink(e.out, out.String(), vm.curStdout()) + vm.writeSink(e.err, errOut.String(), vm.curStderr()) + return code +} + +// writeSink delivers one of the child's streams to its redirection target: a +// named file (created, or appended to for the ["name", "a"] form), an IO, or — +// with no redirection — the interpreter's own stream, which is what a real child +// inherits. +func (vm *VM) writeSink(sink *spawnSink, data string, dflt *IOObj) { + if sink == nil { + dflt.writeStr(data) + return + } + if sink.io != nil { + sink.io.writeStr(data) + return + } + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if sink.append { + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + f, err := os.OpenFile(sink.path, flags, 0o644) + if err != nil { + // MRI opens a redirection target in the CHILD, before the exec, and reports + // the failure back to the parent — so a bad :out path raises here, named + // the way rb_sys_fail names it. A later short write would be the child's + // own failure, which no parent ever sees. + sysFail(err, sink.path) + } + defer f.Close() + _, _ = f.WriteString(data) +} + +// reapAny removes and returns a recorded child: the one with the given pid, or +// the oldest still unreaped when pid is -1 or 0 (wait's "any child" form). +func (vm *VM) reapAny(pid int) (childStatus, bool) { + if pid > 0 { + return vm.reapChild(pid) + } + if len(vm.children) == 0 { + return childStatus{}, false + } + c := vm.children[0] + vm.children = vm.children[1:] + return c, true +} + +// waitForChild is the body shared by Process.wait / wait2 / waitpid / waitpid2: +// it coerces the optional pid and flags, reaps a child, and sets $?. A missing +// child is Errno::ECHILD, unless a non-zero flag (WNOHANG) was passed, which +// reports "nothing ready" instead. +func (vm *VM) waitForChild(args []object.Value) (childStatus, bool) { + pid := -1 + if len(args) > 0 && !object.IsNil(args[0]) { + pid = int(vm.procToInt(args[0])) + } + flags := 0 + if len(args) > 1 && !object.IsNil(args[1]) { + flags = int(vm.procToInt(args[1])) + } + st, ok := vm.reapAny(pid) + if !ok { + if flags != 0 { + return childStatus{}, false + } + raise("Errno::ECHILD", "No child processes") + } + vm.globals["$?"] = vm.newProcessStatus(int64(st.pid), st.code) + return st, true +} + +// registerProcessWait installs the wait family, Process.last_status and +// Process::Status.wait. process.c: proc_wait / proc_wait2 / proc_waitall are +// the same three entry points, and "waitpid" / "waitpid2" are MRI's aliases of +// the first two (rb_define_module_function twice over the same C function), so +// they share one Method object here and compare equal through Method#==, which +// is what core/process/waitpid_spec.rb asserts. +func (vm *VM) registerProcessWait(mod *RClass, status *RClass) { + wait := &Method{name: "wait", owner: mod, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + st, ok := vm.waitForChild(args) + if !ok { + return object.NilV + } + return object.IntValue(int64(st.pid)) + }} + wait2 := &Method{name: "wait2", owner: mod, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + st, ok := vm.waitForChild(args) + if !ok { + return object.NilV + } + return object.NewArray(object.IntValue(int64(st.pid)), vm.newProcessStatus(int64(st.pid), st.code)) + }} + mod.smethods["wait"], mod.smethods["waitpid"] = wait, wait + mod.smethods["wait2"], mod.smethods["waitpid2"] = wait2, wait2 + + mod.smethods["waitall"] = &Method{name: "waitall", owner: mod, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 0 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 0)", len(args)) + } + pairs := object.NewArray() + for len(vm.children) > 0 { + st, _ := vm.reapAny(-1) + vm.globals["$?"] = vm.newProcessStatus(int64(st.pid), st.code) + pairs.Elems = append(pairs.Elems, + object.NewArray(object.IntValue(int64(st.pid)), vm.newProcessStatus(int64(st.pid), st.code))) + } + return pairs + }} + + // Process.last_status is $? for the current thread; it takes no arguments. + mod.smethods["last_status"] = &Method{name: "last_status", owner: mod, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 0 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 0)", len(args)) + } + if st, ok := vm.globals["$?"]; ok { + return st + } + return object.NilV + }} + + // Process::Status.wait has the same signature as Process.wait but answers + // with a Status rather than a pid, and — unlike Process.wait — reports "no + // children" as a Status for pid -1 instead of raising. + status.smethods["wait"] = &Method{name: "wait", owner: status, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + st, ok := vm.reapAny(waitPidArg(vm, args)) + if !ok { + return vm.newProcessStatus(-1, 0) + } + vm.globals["$?"] = vm.newProcessStatus(int64(st.pid), st.code) + return vm.globals["$?"] + }} +} + +// waitPidArg reads the optional pid argument of a wait-style call. +func waitPidArg(vm *VM, args []object.Value) int { + if len(args) > 0 && !object.IsNil(args[0]) { + return int(vm.procToInt(args[0])) + } + return -1 +} diff --git a/internal/vm/spawn_execarg_internal_test.go b/internal/vm/spawn_execarg_internal_test.go new file mode 100644 index 00000000..f084cff5 --- /dev/null +++ b/internal/vm/spawn_execarg_internal_test.go @@ -0,0 +1,433 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !windows && !wasm + +package vm + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeSpawn installs a runSpawnProc that records each prepared child and writes +// fixed text to its two streams, so the argument-peeling and redirection paths +// can be asserted without running anything. +func fakeSpawn(t *testing.T, out, errOut string, code int) *[]*spawnReq { + t.Helper() + var got []*spawnReq + orig := runSpawnProc + runSpawnProc = func(r *spawnReq) int { + got = append(got, r) + _, _ = r.stdout.Write([]byte(out)) + _, _ = r.stderr.Write([]byte(errOut)) + return code + } + t.Cleanup(func() { runSpawnProc = orig }) + return &got +} + +// TestExecArgCommandForms covers rb_exec_getargs + rb_check_argv: the shell +// form, an explicit argv, the [prog, argv0] command array, and the #to_ary / +// #to_str conversions each of them accepts. +func TestExecArgCommandForms(t *testing.T) { + for _, tc := range []struct { + src string + wantShell string + wantArgv []string + wantProgIn string // substring the resolved path must contain + }{ + {src: `Process.spawn("echo hi there")`, wantShell: "echo hi there"}, + {src: `Process.spawn("echo", "hi")`, wantArgv: []string{"echo", "hi"}, wantProgIn: "echo"}, + {src: `Process.spawn(["/bin/echo", "argv_zero"], "hi")`, + wantArgv: []string{"argv_zero", "hi"}, wantProgIn: "/bin/echo"}, + // #to_ary on the first argument, #to_str on each element. + {src: `o = Object.new; def o.to_ary; ["/bin/echo", "zero"]; end; Process.spawn(o, "hi")`, + wantArgv: []string{"zero", "hi"}, wantProgIn: "/bin/echo"}, + {src: `o = Object.new; def o.to_str; "hi"; end; Process.spawn("echo", o)`, + wantArgv: []string{"echo", "hi"}, wantProgIn: "echo"}, + // A command array is never handed to the shell even with one argument. + {src: `Process.spawn(["/bin/echo", "a b"])`, wantArgv: []string{"a b"}, wantProgIn: "/bin/echo"}, + } { + got := fakeSpawn(t, "", "", 0) + eval(t, tc.src) + if len(*got) != 1 { + t.Fatalf("%s: ran %d commands", tc.src, len(*got)) + } + r := (*got)[0] + if r.shell != tc.wantShell { + t.Errorf("%s: shell = %q want %q", tc.src, r.shell, tc.wantShell) + } + if tc.wantShell == "" && strings.Join(r.argv, "\x00") != strings.Join(tc.wantArgv, "\x00") { + t.Errorf("%s: argv = %v want %v", tc.src, r.argv, tc.wantArgv) + } + if tc.wantProgIn != "" && !strings.Contains(r.path, tc.wantProgIn) { + t.Errorf("%s: path = %q want one containing %q", tc.src, r.path, tc.wantProgIn) + } + } +} + +// TestExecArgErrors pins every argument error rb_check_argv, check_exec_env_i +// and check_exec_options_i raise, byte for byte against MRI 4.0.5. +func TestExecArgErrors(t *testing.T) { + fakeSpawn(t, "", "", 0) + for _, tc := range []struct{ src, class, msg string }{ + {`Process.spawn`, "ArgumentError", "wrong number of arguments (given 0, expected 1+)"}, + {`Process.spawn({})`, "ArgumentError", "wrong number of arguments (given 0, expected 1+)"}, + {`Process.spawn({}, {})`, "ArgumentError", "wrong number of arguments (given 0, expected 1+)"}, + {`Process.spawn(:echo)`, "TypeError", "no implicit conversion of Symbol into String"}, + {`Process.spawn("echo", :foo)`, "TypeError", "no implicit conversion of Symbol into String"}, + {`o = Object.new; def o.to_str; 1; end; Process.spawn(o)`, "TypeError", "can't convert Object to String"}, + {"Process.spawn(\"\\000\")", "ArgumentError", "string contains null byte"}, + {"Process.spawn(\"echo\", \"\\000\")", "ArgumentError", "string contains null byte"}, + {`Process.spawn([])`, "ArgumentError", "wrong first argument"}, + {`Process.spawn([:a])`, "ArgumentError", "wrong first argument"}, + {`Process.spawn([:a, :b, :c])`, "ArgumentError", "wrong first argument"}, + {`Process.spawn([:echo, "echo"])`, "TypeError", "no implicit conversion of Symbol into String"}, + {`Process.spawn({"FOO=" => "BAR"}, "echo")`, "ArgumentError", "environment name contains a equal : FOO="}, + {"Process.spawn({\"\\000\" => \"BAR\"}, \"echo\")", "ArgumentError", "string contains null byte"}, + {"Process.spawn({\"FOO\" => \"\\000\"}, \"echo\")", "ArgumentError", "string contains null byte"}, + {`Process.spawn("echo", pgroup: -1)`, "ArgumentError", "negative process group ID : -1"}, + {`Process.spawn("echo", pgroup: :true)`, "TypeError", "no implicit conversion of Symbol into Integer"}, + {`Process.spawn("echo", "chdir" => Dir.pwd)`, "ArgumentError", "wrong exec option"}, + {`Process.spawn("echo", nonesuch: :foo)`, "ArgumentError", "wrong exec option symbol: nonesuch"}, + {`Process.spawn("echo", out: Object.new)`, "ArgumentError", "wrong exec redirect action"}, + {`Process.spawn("echo", out: [])`, "ArgumentError", "wrong exec redirect action"}, + {`Process.spawn("echo", chdir: "no-such-directory-xyz")`, + "Errno::ENOENT", "No such file or directory - no-such-directory-xyz"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } +} + +// TestExecArgOptions covers the option keys that ARE honoured (:chdir, :pgroup's +// accepted shapes, the accepted-and-ignored set) and the environment the child +// ends up with under :unsetenv_others. +func TestExecArgOptions(t *testing.T) { + dir := t.TempDir() + got := fakeSpawn(t, "", "", 0) + + eval(t, `Process.spawn("echo hi", chdir: `+quoteRuby(dir)+`)`) + if (*got)[0].dir != dir { + t.Errorf("chdir: got %q want %q", (*got)[0].dir, dir) + } + // :pgroup false / nil / true / a non-negative pgid are all accepted, and so + // are the options this VM records but cannot apply to a synchronous child. + *got = nil + eval(t, `Process.spawn("echo hi", pgroup: false) +Process.spawn("echo hi", pgroup: nil) +Process.spawn("echo hi", pgroup: true) +Process.spawn("echo hi", pgroup: 0) +Process.spawn("echo hi", umask: 146, close_others: true, exception: false, in: 0)`) + if len(*got) != 5 { + t.Fatalf("accepted options: ran %d commands", len(*got)) + } + + // No environment Hash and no :unsetenv_others ⇒ inherit (a nil environment). + *got = nil + eval(t, `Process.spawn("echo hi")`) + if (*got)[0].env != nil { + t.Errorf("no env Hash: got %v want nil", (*got)[0].env) + } + // An environment Hash overrides, adds and (with a nil value) removes. + *got = nil + eval(t, `ENV["RBGO_SPAWN_BASE"] = "base" +Process.spawn({"RBGO_SPAWN_BASE" => nil, "RBGO_SPAWN_NEW" => "new"}, "echo hi")`) + env := envOf((*got)[0].env) + if _, still := env["RBGO_SPAWN_BASE"]; still { + t.Error("a nil environment value must unset the variable") + } + if env["RBGO_SPAWN_NEW"] != "new" { + t.Errorf("added variable: got %q", env["RBGO_SPAWN_NEW"]) + } + if _, ok := env["PATH"]; !ok { + t.Error("without :unsetenv_others the child still inherits PATH") + } + // :unsetenv_others keeps ONLY the Hash's own names. + *got = nil + eval(t, `Process.spawn({"ONLY" => "1"}, "echo hi", unsetenv_others: true)`) + if env := envOf((*got)[0].env); len(env) != 1 || env["ONLY"] != "1" { + t.Errorf("unsetenv_others: got %v want only ONLY=1", env) + } + // :unsetenv_others false leaves the inherited environment in place. + *got = nil + eval(t, `Process.spawn({"ONLY" => "1"}, "echo hi", unsetenv_others: false)`) + if env := envOf((*got)[0].env); len(env) < 2 { + t.Errorf("unsetenv_others false: got %v", env) + } + // :unsetenv_others with no environment Hash at all still narrows the child's + // environment to nothing. + *got = nil + eval(t, `Process.spawn("echo hi", unsetenv_others: true)`) + if env := envOf((*got)[0].env); len(env) != 0 { + t.Errorf("unsetenv_others without an env Hash: got %v", env) + } + // #to_hash makes an object the environment; #to_str converts its keys/values. + *got = nil + eval(t, `o = Object.new; def o.to_hash; {"VIA_TO_HASH" => "1"}; end +Process.spawn(o, "echo hi", unsetenv_others: true) +k = Object.new; def k.to_str; "K"; end +v = Object.new; def v.to_str; "V"; end +Process.spawn({k => v}, "echo hi", unsetenv_others: true)`) + if env := envOf((*got)[0].env); env["VIA_TO_HASH"] != "1" { + t.Errorf("#to_hash environment: got %v", env) + } + if env := envOf((*got)[1].env); env["K"] != "V" { + t.Errorf("#to_str environment key/value: got %v", env) + } + // A String or an Array in the leading slot is a command, never the + // environment, and an object with no #to_hash likewise. + *got = nil + eval(t, `o = Object.new; def o.to_str; "echo hi"; end; Process.spawn(o)`) + if (*got)[0].shell != "echo hi" { + t.Errorf("a #to_str object is the command: got %+v", (*got)[0]) + } +} + +// TestExecArgRedirection covers :out / :err delivery — to a file, to an IO, to +// the interpreter's own streams, and the folds this VM treats as already +// merged. +func TestExecArgRedirection(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "out.txt") + fakeSpawn(t, "to-stdout", "to-stderr", 0) + + // out: String truncates; ["name", "a"] appends; ["name", "w"] truncates. + eval(t, `Process.spawn("echo hi", out: `+quoteRuby(name)+`)`) + mustRead(t, name, "to-stdout") + eval(t, `Process.spawn("echo hi", out: [`+quoteRuby(name)+`, "a"])`) + mustRead(t, name, "to-stdoutto-stdout") + eval(t, `Process.spawn("echo hi", out: [`+quoteRuby(name)+`, "w"])`) + mustRead(t, name, "to-stdout") + // A bare [name] Array opens for writing too. + eval(t, `Process.spawn("echo hi", out: [`+quoteRuby(name)+`])`) + mustRead(t, name, "to-stdout") + // err: String. + eval(t, `Process.spawn("echo hi", err: `+quoteRuby(name)+`)`) + mustRead(t, name, "to-stderr") + + // out: IO, and an object that answers #to_io. + if out := eval(t, `require "stringio" +io = StringIO.new(+"") +Process.spawn("echo hi", out: io) +print io.string`); out != "to-stderrto-stdout" { + t.Errorf("out: IO got %q", out) + } + if out := eval(t, `require "stringio" +io = StringIO.new(+"") +o = Object.new +o.define_singleton_method(:to_io) { io } +Process.spawn("echo hi", out: o) +print io.string`); out != "to-stderrto-stdout" { + t.Errorf("out: #to_io got %q", out) + } + // With no redirection the child's streams are the interpreter's own (which + // this harness gives one buffer, so both land there in order). + if out := eval(t, `Process.spawn("echo hi")`); out != "to-stdoutto-stderr" { + t.Errorf("inherited stdout: got %q", out) + } + // The folds this VM cannot express are accepted and merged: a descriptor + // number, :close, and [:child, :out]. + eval(t, `Process.spawn("echo hi", out: 5, err: :close) +Process.spawn("echo hi", err: [:child, :out])`) + + // A redirection target that cannot be opened raises the open's errno. + class, msg := evalErr(t, `Process.spawn("echo hi", out: `+quoteRuby(filepath.Join(dir, "no", "such", "f"))+`)`) + if class != "Errno::ENOENT" || !strings.HasPrefix(msg, "No such file or directory - ") { + t.Errorf("unopenable :out: got %s/%q", class, msg) + } +} + +// TestSpawnResolve covers the program lookup that decides which errno a command +// that cannot be run reports — the one MRI learns from its child's failed +// execve. It exercises the real resolver, not a stub. +func TestSpawnResolve(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "runnable") + if err := os.WriteFile(exe, []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil { + t.Fatal(err) + } + plain := filepath.Join(dir, "plain") + if err := os.WriteFile(plain, []byte("not executable"), 0o644); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + prog, env string + wantOK bool + wantClass, wantMsg string + }{ + {prog: exe, wantOK: true}, + {prog: filepath.Join(dir, "missing"), wantClass: "Errno::ENOENT", + wantMsg: "No such file or directory - " + filepath.Join(dir, "missing")}, + {prog: dir, wantClass: "Errno::EACCES", wantMsg: "Permission denied - " + dir}, + {prog: plain, wantClass: "Errno::EACCES", wantMsg: "Permission denied - " + plain}, + // A bare name is looked up in the CHILD's PATH. + {prog: "runnable", env: dir, wantOK: true}, + {prog: "plain", env: dir, wantClass: "Errno::ENOENT", + wantMsg: "No such file or directory - plain"}, + {prog: "runnable", env: "", wantClass: "Errno::ENOENT", + wantMsg: "No such file or directory - runnable"}, + } { + var env []string + if !strings.ContainsRune(tc.prog, os.PathSeparator) { + env = []string{"PATH=" + tc.env} + } + path, class, msg, ok := spawnResolve(tc.prog, env) + if ok != tc.wantOK { + t.Errorf("spawnResolve(%q, PATH=%q): ok = %v want %v (%s/%s)", tc.prog, tc.env, ok, tc.wantOK, class, msg) + continue + } + if !ok && (class != tc.wantClass || msg != tc.wantMsg) { + t.Errorf("spawnResolve(%q): got %s/%q want %s/%q", tc.prog, class, msg, tc.wantClass, tc.wantMsg) + } + if ok && path == "" { + t.Errorf("spawnResolve(%q): resolved to an empty path", tc.prog) + } + } + // An empty PATH element means the current directory, which is how a child + // with PATH=":/bin" searches. + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(cwd) }() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + if _, _, _, ok := spawnResolve("runnable", []string{"PATH=:" + cwd}); !ok { + t.Error("an empty PATH element must search the current directory") + } + // With no environment override the resolver reads the interpreter's own PATH. + if _, _, _, ok := spawnResolve("no-such-program-xyz", nil); ok { + t.Error("a missing program must not resolve against the inherited PATH") + } + if got := spawnEnvGet(nil, "PATH"); got != os.Getenv("PATH") { + t.Errorf("spawnEnvGet(nil): got %q", got) + } + if got := spawnEnvGet([]string{"A=1"}, "MISSING"); got != "" { + t.Errorf("spawnEnvGet(missing): got %q", got) + } +} + +// TestSpawnFailureStatus: a command that cannot be started raises its errno and +// leaves $? reporting the 127 the child MRI forked would have exited with — and +// leaves nothing for Process.wait to reap, because that child is already reaped. +func TestSpawnFailureStatus(t *testing.T) { + fakeSpawn(t, "", "", 0) + if got := eval(t, `begin + Process.spawn("no-such-program-xyz") +rescue Errno::ENOENT => e + p [e.message, $?.exitstatus, Process.waitall] +end`); got != `["No such file or directory - no-such-program-xyz", 127, []]`+"\n" { + t.Errorf("failed spawn: got %q", got) + } + if got := eval(t, `begin + Process.spawn("echo", chdir: "no-such-directory-xyz") +rescue Errno::ENOENT + p [$?.exitstatus, Process.waitall] +end`); got != "[127, []]\n" { + t.Errorf("failed chdir: got %q", got) + } +} + +// TestProcessWaitFamily covers wait / wait2 / waitpid / waitpid2 / waitall / +// last_status and Process::Status.wait over the recorded-child model. +func TestProcessWaitFamily(t *testing.T) { + fakeSpawn(t, "", "", 7) + for _, tc := range []struct{ src, want string }{ + // wait with no argument takes the oldest child and sets $?. + {`pid = Process.spawn("echo hi"); p Process.wait == pid, $?.exitstatus`, "true\n7\n"}, + {`pid = Process.spawn("echo hi"); p Process.wait(pid) == pid`, "true\n"}, + {`pid = Process.spawn("echo hi"); p Process.wait(pid, 0) == pid`, "true\n"}, + {`pid = Process.spawn("echo hi"); p Process.wait(nil, nil) == pid`, "true\n"}, + {`pid = Process.spawn("echo hi"); p Process.wait2(pid).map { |v| v.class }`, "[Integer, Process::Status]\n"}, + // waitpid / waitpid2 are the very same methods, as MRI's aliases are. + {`p Process.method(:waitpid) == Process.method(:wait)`, "true\n"}, + {`p Process.method(:waitpid2) == Process.method(:wait2)`, "true\n"}, + {`pid = Process.spawn("echo hi"); p Process.waitpid(pid) == pid`, "true\n"}, + {`pid = Process.spawn("echo hi"); p Process.waitpid2(pid)[1].exitstatus`, "7\n"}, + // waitall reaps everything, in order, and empties the table. + {`a = Process.spawn("echo hi"); b = Process.spawn("echo hi") +p Process.waitall.map(&:first) == [a, b], Process.waitall`, "true\n[]\n"}, + // WNOHANG on a child that is not there reports "nothing ready". + {`p Process.wait(999999, Process::WNOHANG)`, "nil\n"}, + {`p Process.wait2(999999, Process::WNOHANG)`, "nil\n"}, + // last_status is $?, and nil before any child has run. + {`p Process.last_status`, "nil\n"}, + {`Process.wait Process.spawn("echo hi"); p Process.last_status.exitstatus`, "7\n"}, + // Process::Status.wait answers with a Status, and with pid -1 when there + // is no child rather than raising. + {`p Process::Status.wait.pid`, "-1\n"}, + {`pid = Process.spawn("echo hi"); p Process::Status.wait(pid).exitstatus`, "7\n"}, + {`pid = Process.spawn("echo hi"); p Process::Status.wait.pid == pid`, "true\n"}, + } { + if got := eval(t, tc.src); got != tc.want { + t.Errorf("%s\n got %q want %q", tc.src, got, tc.want) + } + } + for _, tc := range []struct{ src, class, msg string }{ + {`Process.wait`, "Errno::ECHILD", "No child processes"}, + {`Process.wait2`, "Errno::ECHILD", "No child processes"}, + {`Process.waitall(0)`, "ArgumentError", "wrong number of arguments (given 1, expected 0)"}, + {`Process.last_status(1)`, "ArgumentError", "wrong number of arguments (given 1, expected 0)"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } +} + +// TestProcessExecValidates: Process.exec is reachable with an explicit receiver +// (Kernel#exec alone is private) and rejects a command it cannot run before +// anything is executed. +func TestProcessExecValidates(t *testing.T) { + fakeSpawn(t, "execed", "", 0) + for _, tc := range []struct{ src, class, msg string }{ + {`Process.exec([])`, "ArgumentError", "wrong first argument"}, + {`Process.exec("")`, "Errno::ENOENT", "No such file or directory - "}, + {`Process.exec("no-such-program-xyz")`, "Errno::ENOENT", "No such file or directory - no-such-program-xyz"}, + {"Process.exec(\"\\000\")", "ArgumentError", "string contains null byte"}, + } { + class, msg := evalErr(t, tc.src) + if class != tc.class || msg != tc.msg { + t.Errorf("%s: got %s/%q want %s/%q", tc.src, class, msg, tc.class, tc.msg) + } + } + // A command that CAN run unwinds the enclosing fork block with its status. + if got := eval(t, `pid = Kernel.fork { Process.exec("echo hi") } +p Process.waitpid2(pid)[1].exitstatus`); got != "execed0\n" { + t.Errorf("Process.exec inside fork: got %q", got) + } +} + +// mustRead asserts a file's exact contents. +func mustRead(t *testing.T, name, want string) { + t.Helper() + b, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if string(b) != want { + t.Errorf("%s: got %q want %q", name, b, want) + } +} + +// envOf turns an environment slice into a map for assertions. +func envOf(env []string) map[string]string { + m := map[string]string{} + for _, kv := range env { + k, v, _ := strings.Cut(kv, "=") + m[k] = v + } + return m +} + +// quoteRuby renders a path as a Ruby string literal. +func quoteRuby(s string) string { return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` } diff --git a/internal/vm/spawn_internal_test.go b/internal/vm/spawn_internal_test.go index 6e397e65..4c69e06a 100644 --- a/internal/vm/spawn_internal_test.go +++ b/internal/vm/spawn_internal_test.go @@ -8,6 +8,7 @@ package vm import ( "bytes" + "io" "os/exec" "runtime" "strings" @@ -26,7 +27,21 @@ func runSpawn(t *testing.T, src string, fake func([]string) (string, int)) strin t.Helper() orig := runCaptured runCaptured = fake - defer func() { runCaptured = orig }() + // Process.spawn / Process.exec go through the richer runSpawnProc seam and + // resolve their program before running it; both are driven from the same fake + // so these tests stay independent of what the host has in PATH. + origProc, origResolve := runSpawnProc, spawnResolve + runSpawnProc = func(r *spawnReq) int { + cmd := r.argv + if r.shell != "" { + cmd = []string{r.shell} + } + out, code := fake(cmd) + _, _ = io.WriteString(r.stdout, out) + return code + } + spawnResolve = func(prog string, _ []string) (string, string, string, bool) { return prog, "", "", true } + defer func() { runCaptured, runSpawnProc, spawnResolve = orig, origProc, origResolve }() prog, err := parser.Parse(src) if err != nil { diff --git a/internal/vm/spawn_native.go b/internal/vm/spawn_native.go index cf04c873..ae745e98 100644 --- a/internal/vm/spawn_native.go +++ b/internal/vm/spawn_native.go @@ -163,3 +163,25 @@ var ( func timevalSeconds(tv unix.Timeval) float64 { return float64(tv.Sec) + float64(tv.Usec)/1e6 } + +// runSpawnProc runs one prepared child to completion, writing its two output +// streams to the request's sinks and returning its exit code. It is the richer +// counterpart of runCaptured: Process.spawn needs the child's environment, its +// working directory and its two streams kept apart, none of which a combined +// capture can express. A package var so tests drive every caller without real +// processes. +var runSpawnProc = func(r *spawnReq) int { + var c *exec.Cmd + if r.shell != "" { + c = exec.Command("/bin/sh", "-c", r.shell) + } else { + // The program was already resolved against the CHILD's PATH; os/exec would + // otherwise look it up in the parent's, which is a different search. + c = exec.Command(r.path, r.argv[1:]...) + // The [prog, argv0] command-array form tells the child a name other than + // the file that was executed, so argv[0] is set apart from the path. + c.Args[0] = r.argv[0] + } + c.Dir, c.Env, c.Stdout, c.Stderr = r.dir, r.env, r.stdout, r.stderr + return exitCodeOf(c.Run()) +} diff --git a/internal/vm/spawn_wasm.go b/internal/vm/spawn_wasm.go index 5ab37372..fa016b2b 100644 --- a/internal/vm/spawn_wasm.go +++ b/internal/vm/spawn_wasm.go @@ -49,3 +49,10 @@ var ( procKill func(pid, sig int) error procRusage func() (utime, stime float64) ) + +// runSpawnProc has no meaning under wasm (no subprocesses), so Process.spawn +// raises NotImplementedError there rather than silently succeeding. +var runSpawnProc = func(*spawnReq) int { + raise("NotImplementedError", "subprocess execution is not supported on wasm") + return 127 +} diff --git a/internal/vm/spawn_windows.go b/internal/vm/spawn_windows.go index b11e2127..2c3c73dd 100644 --- a/internal/vm/spawn_windows.go +++ b/internal/vm/spawn_windows.go @@ -89,3 +89,17 @@ var ( procKill func(pid, sig int) error procRusage func() (utime, stime float64) ) + +// runSpawnProc runs one prepared child to completion (see spawn_native.go for +// the shape and why it is a package var); the shell wrapper is cmd.exe here. +var runSpawnProc = func(r *spawnReq) int { + var c *exec.Cmd + if r.shell != "" { + c = exec.Command("cmd", "/c", r.shell) + } else { + c = exec.Command(r.path, r.argv[1:]...) + c.Args[0] = r.argv[0] + } + c.Dir, c.Env, c.Stdout, c.Stderr = r.dir, r.env, r.stdout, r.stderr + return exitCodeOf(c.Run()) +} From 29a250c77eaf13e553e05deed1bb3cd3d8aef8ef Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 20 Sep 2026 19:24:52 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Process:=20NUM2RLIM=20must=20take=20a=20Big?= =?UTF-8?q?num=20back=20=E2=80=94=20RLIM=5FINFINITY=20is=20~0=20on=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Linux arch lanes failed TestProcessRlimitCoercion with TypeError: can't convert Object to Integer (Object#to_int gives Object) on `Process.setrlimit(:CORE, *Process.getrlimit(:CORE))`. Darwin cannot see it: its RLIM_INFINITY is 0x7fff_ffff_ffff_ffff, which fits an int64. What it actually is, established rather than guessed. configure.ac's RUBY_REPLACE_TYPE(rlim_t, [int long "long long"], RLIM, ...) resolves rlim_t to "unsigned long long" on both platforms, so RLIM2NUM is ULL2NUM and NUM2RLIM is NUM2ULL. ULL2NUM of a value above LONG_MAX yields a BIGNUM, which is why process.c's own example reads Process.getrlimit(:CORE) # => [0, 18446744073709551615] Reproduced here by feeding the Linux value through the getrlimit seam on Darwin, which printed the same error. That also showed the diagnosis is narrower than "an element is not an Integer": getrlimit was already right, returning [0, 18446744073709551615] with BOTH elements answering is_a?(Integer) — byte-identical to MRI on Linux. The defect was entirely on the way back in. rlimitResourceValue went through procToInt (NUM2INT, int64), which rejected the *object.Bignum as a non-integer; the "Object" in the message is classNameOf not knowing that type. Fixes: - rlimToUint implements NUM2RLIM properly: a Bignum within uint64 is taken at full width, one beyond it is a RangeError naming the C type MRI names ("bignum too big to convert into 'unsigned long long'"), and a negative limit wraps as the cast does — MRI hands setrlimit(:CORE, -1, -1) to the kernel, which answers EPERM. - procToInt reports a Bignum as a RangeError ("bignum too big to convert into 'long'"), which is what NUM2INT/NUM2PIDT do, instead of a TypeError. Any Bignum reaching it is out of int64 range by construction, since object.NormInt demotes everything that fits. All three messages checked against MRI 4.0.5. TestProcessRlimitInfinityRoundTrip pins the Linux representation on every platform through the seam and asserts the round trip is a no-op, so this cannot regress from a Darwin-only run again. TestProcessRlimitSymbolicMatchesTable keeps the real host table checked separately, since a faked getrlimit cannot speak for it. This also unblocks setrlimit_spec's 30 examples on Linux, every one of which is a getrlimit -> setrlimit round trip. Gates: full go test ./... green; gofmt clean; go vet clean for darwin, linux/amd64 and linux/arm64 (which type-checks the test files for the lanes that failed); builds clean for wasip1, windows, linux/amd64, linux/arm64. internal/vm coverage 99.9%, unchanged, every new function at 100%. Co-Authored-By: Claude Opus 5 --- internal/vm/process.go | 31 ++++++- internal/vm/process_limits_internal_test.go | 93 +++++++++++++++++++-- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/internal/vm/process.go b/internal/vm/process.go index eec018a4..29a4f7c3 100644 --- a/internal/vm/process.go +++ b/internal/vm/process.go @@ -355,7 +355,7 @@ func (vm *VM) rlimitResourceType(v object.Value) int { func (vm *VM) rlimitResourceValue(v object.Value) uint64 { name, named := vm.rlimitName(v) if !named { - return uint64(vm.procToInt(v)) + return vm.rlimToUint(v) } if lim, ok := rlimitValues[name]; ok { return lim @@ -393,6 +393,12 @@ func (vm *VM) procToInt(v object.Value) int64 { if i, ok := v.(object.Integer); ok { return int64(i) } + if _, big := v.(*object.Bignum); big { + // Any Bignum reaching here is out of int64 range by construction — + // object.NormInt demotes everything that fits — and NUM2INT reports that as + // a RangeError, never as the TypeError a non-integer gets. + raise("RangeError", "bignum too big to convert into 'long'") + } if object.IsNil(v) { raise("TypeError", "no implicit conversion from nil to integer") } @@ -408,6 +414,29 @@ func (vm *VM) procToInt(v object.Value) int64 { return int64(i) } +// rlimToUint is NUM2RLIM. rlim_t is UNSIGNED and 64 bits wide on every platform +// MRI builds for — configure.ac's RUBY_REPLACE_TYPE(rlim_t, ...) resolves it to +// "unsigned long long", so RLIM2NUM is ULL2NUM and NUM2RLIM is NUM2ULL — and +// ULL2NUM of a value above LONG_MAX is a BIGNUM, not a Fixnum. That is not a +// corner case: RLIM_INFINITY is ~0 on Linux, so process.c's own example reads +// +// Process.getrlimit(:CORE) # => [0, 18446744073709551615] +// +// and the getrlimit -> setrlimit round trip the specs make (and that +// Process.setrlimit(:CORE, *Process.getrlimit(:CORE)) is) hands this function a +// Bignum on Linux where it hands it a Fixnum on Darwin. Both have to arrive as +// the same 64 bits. A negative limit wraps, as NUM2ULL's cast does — MRI takes +// setrlimit(:CORE, -1, -1) all the way to the kernel, which answers EPERM. +func (vm *VM) rlimToUint(v object.Value) uint64 { + if b, ok := v.(*object.Bignum); ok { + if !b.I.IsUint64() { + raise("RangeError", "bignum too big to convert into 'unsigned long long'") + } + return b.I.Uint64() + } + return uint64(vm.procToInt(v)) +} + // errnoClasses inverts errnoNumbers so a failed syscall can name its Errno::Exxx // class. Names are folded in sorted order so a platform where two names share a // number (EAGAIN/EWOULDBLOCK on some kernels) always picks the same one. diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go index 871c8f1c..4cbb2c97 100644 --- a/internal/vm/process_limits_internal_test.go +++ b/internal/vm/process_limits_internal_test.go @@ -100,17 +100,75 @@ p Process.getrlimit(o) == Process.getrlimit(:CORE)`, "true\n"}, } } -// TestProcessRlimitInfinityPromotes drives the seam with a limit that does not -// fit in an int64 — RLIM_INFINITY is ~0 on Linux — and asserts it comes back as -// a Bignum rather than wrapping negative. -func TestProcessRlimitInfinityPromotes(t *testing.T) { - withGetrlimit(t, func(int) (uint64, uint64, error) { return 1, ^uint64(0), nil }, func() { - if got := eval(t, `p Process.getrlimit(:CORE)`); got != "[1, 18446744073709551615]\n" { - t.Errorf("getrlimit with an out-of-int64 limit: got %q", got) +// TestProcessRlimitInfinityRoundTrip pins the LINUX representation of +// RLIM_INFINITY on every platform. rlim_t is unsigned and 64 bits wide, so on +// Linux RLIM_INFINITY is ~0, which exceeds LONG_MAX and therefore crosses into +// Ruby as a BIGNUM — process.c's own example is +// +// Process.getrlimit(:CORE) # => [0, 18446744073709551615] +// +// Darwin's RLIM_INFINITY is 0x7fff_ffff_ffff_ffff, which fits an int64, so no +// test that only runs there can see this: the seam supplies the Linux value +// instead. What must hold on both is that the pair comes back as two Integers +// and that setrlimit takes the very same 64 bits back — which is exactly what +// Process.setrlimit(:CORE, *Process.getrlimit(:CORE)) asserts, and what failed +// on the two Linux arch lanes with "can't convert Object to Integer". +func TestProcessRlimitInfinityRoundTrip(t *testing.T) { + var applied [][2]uint64 + origSet := procSetrlimit + procSetrlimit = func(_ int, cur, max uint64) error { + applied = append(applied, [2]uint64{cur, max}) + return nil + } + defer func() { procSetrlimit = origSet }() + + withGetrlimit(t, func(int) (uint64, uint64, error) { return 0, ^uint64(0), nil }, func() { + // Both halves are Integers, and the big one prints in full rather than + // wrapping negative. + if got := eval(t, `r = Process.getrlimit(:CORE) +p r +p r.map { |v| v.is_a?(Integer) }`); got != "[0, 18446744073709551615]\n[true, true]\n" { + t.Errorf("getrlimit with the Linux RLIM_INFINITY: got %q", got) + } + // The round trip is a no-op: every bit that came out goes back in. + if got := eval(t, `p Process.setrlimit(:CORE, *Process.getrlimit(:CORE))`); got != "nil\n" { + t.Errorf("round trip: got %q", got) + } + if len(applied) != 1 || applied[0] != [2]uint64{0, ^uint64(0)} { + t.Errorf("round trip applied %v want [[0 %d]]", applied, uint64(1<<64-1)) } }) } +// TestProcessRlimitRangeErrors: an integer too wide even for rlim_t is a +// RangeError, and so is one too wide for the int a resource number or a pid is. +// MRI 4.0.5 names the C type in both messages. +func TestProcessRlimitRangeErrors(t *testing.T) { + for _, tc := range []struct{ src, msg string }{ + {`Process.setrlimit(:CORE, 2 ** 70, 0)`, "bignum too big to convert into 'unsigned long long'"}, + {`Process.getrlimit(2 ** 70)`, "bignum too big to convert into 'long'"}, + {`Process.kill(0, 2 ** 70)`, "bignum too big to convert into 'long'"}, + } { + class, msg := evalErr(t, tc.src) + if class != "RangeError" || msg != tc.msg { + t.Errorf("%s: got %s/%q want RangeError/%q", tc.src, class, msg, tc.msg) + } + } + // A negative limit wraps rather than raising, as NUM2ULL's cast does: MRI + // hands setrlimit(:CORE, -1, -1) to the kernel, which answers EPERM. + var applied [][2]uint64 + orig := procSetrlimit + procSetrlimit = func(_ int, cur, max uint64) error { + applied = append(applied, [2]uint64{cur, max}) + return nil + } + defer func() { procSetrlimit = orig }() + eval(t, `Process.setrlimit(:CORE, -1, -1)`) + if len(applied) != 1 || applied[0] != [2]uint64{^uint64(0), ^uint64(0)} { + t.Errorf("negative limit: got %v want it wrapped to ~0", applied) + } +} + // TestProcessSysFailFallback: an errno with no registered Errno::Exxx class // still surfaces as a rescuable SystemCallError rather than a Go panic. func TestProcessSysFailFallback(t *testing.T) { @@ -397,3 +455,24 @@ func TestKernelSystemEnvHash(t *testing.T) { t.Errorf("system with an env Hash: got %v", got) } } + +// TestProcessRlimitSymbolicMatchesTable: on THIS host, the symbolic limit names +// and the numbers getrlimit reports are the same values — the check the faked +// Linux round trip above cannot make, because it replaces only one of the two. +func TestProcessRlimitSymbolicMatchesTable(t *testing.T) { + var applied [][2]uint64 + orig := procSetrlimit + procSetrlimit = func(_ int, cur, max uint64) error { + applied = append(applied, [2]uint64{cur, max}) + return nil + } + defer func() { procSetrlimit = orig }() + eval(t, `Process.setrlimit(:CORE, 0, :INFINITY) +Process.setrlimit(:CORE, 0, Process::RLIM_INFINITY)`) + if len(applied) != 2 || applied[0] != applied[1] { + t.Errorf(":INFINITY and Process::RLIM_INFINITY disagree: %v", applied) + } + if applied[0][1] != rlimitValues["INFINITY"] { + t.Errorf("symbolic INFINITY = %d want the platform's %d", applied[0][1], rlimitValues["INFINITY"]) + } +}