diff --git a/internal/vm/process.go b/internal/vm/process.go index 1d596190..29a4f7c3 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" ) @@ -61,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 { @@ -88,6 +98,9 @@ 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) + vm.registerProcessResiduals(mod, def) } // Clock identifiers match the Linux/macOS values MRI exposes (CLOCK_REALTIME=0, @@ -135,3 +148,532 @@ 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 vm.rlimToUint(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 _, 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") + } + 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) +} + +// 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. +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) { + // 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 + } + } + 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) +} + +// --------------------------------------------------------------------------- +// 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 new file mode 100644 index 00000000..4cbb2c97 --- /dev/null +++ b/internal/vm/process_limits_internal_test.go @@ -0,0 +1,478 @@ +// 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) + } + } +} + +// 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) { + 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) + } +} + +// 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) + } +} + +// 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"]) + } +} 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 a5c75eab..ae745e98 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,119 @@ 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 +} + +// 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 a147ed94..fa016b2b 100644 --- a/internal/vm/spawn_wasm.go +++ b/internal/vm/spawn_wasm.go @@ -18,3 +18,41 @@ 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) +) + +// 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 d44b4b5b..2c3c73dd 100644 --- a/internal/vm/spawn_windows.go +++ b/internal/vm/spawn_windows.go @@ -58,3 +58,48 @@ 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) +) + +// 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()) +}