Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
554 changes: 548 additions & 6 deletions internal/vm/process.go

Large diffs are not rendered by default.

478 changes: 478 additions & 0 deletions internal/vm/process_limits_internal_test.go

Large diffs are not rendered by default.

666 changes: 610 additions & 56 deletions internal/vm/spawn.go

Large diffs are not rendered by default.

433 changes: 433 additions & 0 deletions internal/vm/spawn_execarg_internal_test.go

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion internal/vm/spawn_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package vm

import (
"bytes"
"io"
"os/exec"
"runtime"
"strings"
Expand All @@ -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 {
Expand Down
119 changes: 119 additions & 0 deletions internal/vm/spawn_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<name> 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_<name> 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())
}
38 changes: 38 additions & 0 deletions internal/vm/spawn_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
45 changes: 45 additions & 0 deletions internal/vm/spawn_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading