diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08cc3dd8..1f751d0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,9 +137,9 @@ jobs: # embed rbgo on, and nothing else here would notice (the lanes above run six # of them). go vet type-checks every package with its tests for the target. # android and ios link only through cgo, so there the library packages are - # built (no link) instead. plan9 is excluded: its syscall package has no - # errno numbers and no POSIX process or file-lock calls, which rbgo's IO, - # Errno and Process layers are written against (see README "Platforms"). + # built (no link) instead. plan9 is vetted like the rest (386, amd64, arm), but + # nothing RUNS there: no CI lane has a Plan 9 kernel, so its tests only + # compile (see README "Platforms" for what works there). every-target: name: compile on every target (${{ matrix.goos }}) runs-on: ubuntu-latest @@ -148,7 +148,7 @@ jobs: strategy: fail-fast: false matrix: - goos: [aix, android, darwin, dragonfly, freebsd, illumos, ios, js, linux, netbsd, openbsd, solaris, wasip1, windows] + goos: [aix, android, darwin, dragonfly, freebsd, illumos, ios, js, linux, netbsd, openbsd, plan9, solaris, wasip1, windows] steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 diff --git a/README.md b/README.md index 72e1097e..9db2e3dc 100644 --- a/README.md +++ b/README.md @@ -775,7 +775,7 @@ Otherwise: | **wasip1/wasm** | built with `CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm` and **run** under [wazero](https://wazero.io), a pure-Go runtime — verified here: `wazero run rbgo.wasm -e 'puts (1..10).sum'` prints `55` | | **js/wasm (browser)** | `CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./...` plus `go vet` over `./cmd/wasm ./cmd/rbgo ./internal/vm`, then the playground module is **run** in node by `web/smoke.mjs` (`wasm (js, browser)` in `ci.yml`). Added with #684, which fixed the three `syscall` constants the `js` port does not define | | **rbgo_core** | `go vet` and the `internal/vm` suite with `-tags rbgo_core`, then the core playground module is built for js/wasm, its size bounded, and **run** in node by `web/smoke.mjs --core` (`rbgo_core (core build)` in `ci.yml`). See [the `rbgo_core` build](#the-rbgo_core-build-the-language-and-the-numeric-stack-no-gems) | -| **every other target** | `go vet ./...`, default and `rbgo_core` builds, for **every GOOS/GOARCH pair `go tool dist list` names except plan9** — aix, android, darwin, dragonfly, freebsd, illumos, ios, js, linux (all 13 arches, mips and ppc64 included), netbsd, openbsd, solaris, wasip1, windows (`compile on every target` in `ci.yml`; android and ios link only through cgo, so their library packages are built instead). Compiling is all it checks there | +| **every other target** | `go vet ./...`, default and `rbgo_core` builds, for **every GOOS/GOARCH pair `go tool dist list` names** — aix, android, darwin, dragonfly, freebsd, illumos, ios, js, linux (all 13 arches, mips and ppc64 included), netbsd, openbsd, plan9 (386, amd64, arm), solaris, wasip1, windows (`compile on every target` in `ci.yml`; android and ios link only through cgo, so their library packages are built instead). Compiling is all it checks there: **no CI lane runs anything on Plan 9**, there being no Plan 9 kernel to run it on | What a target lacks, rbgo leaves out the way MRI does: @@ -783,7 +783,13 @@ What a target lacks, rbgo leaves out the way MRI does: - **Errno**: a name the system's `` lacks is still a constant, naming `Errno::NOERROR`, as MRI's `undefined_error` does: `EMULTIHOP` and `ENOLINK` on OpenBSD, `ENOTRECOVERABLE` and `EOWNERDEAD` on NetBSD and DragonFly. - **`Process::RLIMIT_*`** exist only where the system has the resource: no `RLIMIT_AS` on OpenBSD, no `RLIMIT_MEMLOCK` on AIX, only `RLIMIT_AS` of the four optional ones on Solaris and illumos. - **`File#flock`** and PStore's lock use `flock(2)`; AIX has none, so there it is emulated with `fcntl(2)` record locks, exactly as MRI's `missing/flock.c` does. -- **plan9 does not compile.** Its `syscall` package has no errno numbers (`syscall.EINVAL` is a string error there), no `Kill`, `Flock`, `Getrlimit` or `Stat_t`, and several gem drivers (nats, Kafka's franz-go, Thrift, gopher-lua) do not build for it; a port means a plan9 variant of rbgo's Errno, IO and Process layers. +- **plan9 (386, amd64, arm) compiles and vets, and is untested at run time.** Its `syscall` package has no errno numbers (`syscall.EINVAL` is a string error there), no `Kill`, `Flock`, `Getrlimit`, `Umask` or `Stat_t`, so rbgo has a plan9 variant of each seam. Nothing has been run on a Plan 9 kernel; the one part with tests is the error-string mapping, which is pure Go and runs on every platform. What that means: + - **Errno**: every `Errno::Exxx` class exists, with the **Linux** number in `Errno::Exxx::Errno` and glibc's wording in the message (MRI's fallback for a name the platform lacks). A failed call's Go error is mapped to a class by the Go standard library's own rules (`syscall.ErrorString.Is`, `os/error_plan9.go`): "file does not exist" is `ENOENT`, "file already exists" `EEXIST`, "permission denied" `EPERM` or `EACCES`, and so on. A string no rule recognises raises plain `SystemCallError` carrying the message. + - **`Process.kill`** works: it writes a note to `/proc//note`. `KILL`, `INT`, `HUP`, `ALRM`, `ABRT` and `TERM` (as `interrupt`, like Go's `syscall.SIGTERM`) have notes; other signals, and a negative pid (a process group), raise `Errno::EINVAL`; signal `0` checks `/proc/` exists. `Process.getrlimit`, `setrlimit`, `getpriority`, `setpriority`, `getpgid`, `getpgrp`, `setpgid`, `setpgrp` and `getsid` raise `NotImplementedError`, and `Process.times` reports no CPU time. + - **`File#flock`** raises `NotImplementedError`, as MRI does where `flock(2)` is missing; PStore works without its cross-process lock. `File.umask` is a no-op reporting `0o022`; `Dir.chroot` raises `Errno::EPERM`. + - **`File::Stat`** comes from `os.FileInfo` plus the Plan 9 `Dir`: `ino` is the Qid path, `dev` packs the server type and subtype, `nlink` is 1, `uid` and `gid` are 0 (owners are names), and `owned?`/`grpowned?` compare the owner and group names with `/dev/user`. `File.lutime` is `File.utime` (Plan 9 has no symlinks). + - **Subprocesses**: a one-string command line goes through `/bin/rc -c`. + - **Gems**: NATS (nats.go uses `syscall.EPIPE`), Kafka (franz-go uses `syscall.ECONNRESET`) and Parquet (apache/thrift uses `syscall.Sockaddr`) do not build for plan9, so `require "nats"`, `"kafka"` and `"parquet"` succeed and the first real operation (`NATS.connect`, `Kafka.new`, `Parquet::ArrowFileReader.new` …) raises `NotImplementedError: nats is not supported on plan9/amd64`. The SQLite3, Sequel, ActiveRecord, Bolt, Bleve and Etcd gems degrade the same way (no modernc.org/sqlite port). `gopher-lua`, which also fails there, is only a test dependency (through `miniredis`), and those tests are skipped on plan9. **Not on the pull-request path** — validated on a nightly schedule instead: `riscv64`, `loong64`, `ppc64le`, `s390x` and 32-bit `arm`, under QEMU diff --git a/cmd/rbgo/die_signal_native.go b/cmd/rbgo/die_signal_native.go index 2e31b21b..e7640e2a 100644 --- a/cmd/rbgo/die_signal_native.go +++ b/cmd/rbgo/die_signal_native.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !windows +//go:build !wasm && !windows && !plan9 package main diff --git a/cmd/rbgo/die_signal_unsupported.go b/cmd/rbgo/die_signal_unsupported.go index 88e64f6e..a620da96 100644 --- a/cmd/rbgo/die_signal_unsupported.go +++ b/cmd/rbgo/die_signal_unsupported.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build wasm || windows +//go:build wasm || windows || plan9 package main @@ -21,4 +21,8 @@ package main // program killed by a signal, with the platform-specific part left unclaimed. // The two end-to-end rows that assert death BY a signal skip on Windows and say // this is why (cmd/rbgo/exit_status_test.go is POSIX-only for the same reason). +// +// On Plan 9 a process is killed by a NOTE (syscall.Note), not a signal number, and +// there is no 128+signo status to leave behind, so the caller's exit code is all +// that is reported. func dieBySignal(int) {} diff --git a/cmd/rbgo/exit_status_test.go b/cmd/rbgo/exit_status_test.go index c4cf5967..cada6f44 100644 --- a/cmd/rbgo/exit_status_test.go +++ b/cmd/rbgo/exit_status_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !rbgo_closed && !wasm && !windows +//go:build !rbgo_closed && !wasm && !windows && !plan9 package main diff --git a/internal/vm/backends_plan9.go b/internal/vm/backends_plan9.go new file mode 100644 index 00000000..d6edd9f0 --- /dev/null +++ b/internal/vm/backends_plan9.go @@ -0,0 +1,51 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build plan9 && !rbgo_core + +// This file is the GOOS=plan9 counterpart of the gem backends whose Go drivers do +// not compile there: NATS (nats-io/nats.go uses syscall.EPIPE and ECONNRESET), +// Kafka (twmb/franz-go uses syscall.ECONNRESET) and Parquet (apache/arrow-go's +// parquet package pulls apache/thrift, which uses syscall.Sockaddr, Recvfrom and +// EAGAIN). Plan 9's syscall package has no errno numbers and no socket-address +// API, so those packages are compiled out (their files carry !plan9) and the +// bindings degrade the way the store gems do on targets without modernc sqlite +// (bindings_store_stub.go): `require "nats"` still succeeds, the module and its +// exception class exist so a `rescue NATS::Error` loads, and the first real +// operation raises NotImplementedError " is not supported on plan9/". + +package vm + +import "github.com/go-embedded-ruby/ruby/internal/object" + +// classOfMsgBinding is the plan9 half of the classOf seam (bindings_msg.go): the +// NATS, Kafka and Parquet value types do not exist here. +func (vm *VM) classOfMsgBinding(v object.Value) (*RClass, bool) { _, _ = vm, v; return nil, false } + +// registerNATS installs a graceful NATS module: NATS::Error, and the NATS.connect +// factory plus NATS::Client.new, all raising. +func (vm *VM) registerNATS() { + mod := vm.stubBindingModule("NATS", "NATS::Error") + stubSMethods(mod, "nats", "connect") + client := vm.stubBindingClass(mod, "NATS::Client") + stubSMethods(client, "nats", "new") +} + +// registerKafka installs a graceful Kafka module: Kafka::Error, and the Kafka.new +// client factory, which raises. +func (vm *VM) registerKafka() { + mod := vm.stubBindingModule("Kafka", "Kafka::Error") + stubSMethods(mod, "kafka", "new") +} + +// registerParquet installs a graceful Parquet module: Parquet::Error, the +// Parquet.load / .save convenience methods and the ArrowFileReader/ArrowFileWriter +// classes whose .new/.open raise. +func (vm *VM) registerParquet() { + mod := vm.stubBindingModule("Parquet", "Parquet::Error") + stubSMethods(mod, "parquet", "load", "save") + for _, n := range []string{"Parquet::ArrowFileReader", "Parquet::ArrowFileWriter"} { + stubSMethods(vm.stubBindingClass(mod, n), "parquet", "new", "open") + } +} diff --git a/internal/vm/bindings_msg.go b/internal/vm/bindings_msg.go new file mode 100644 index 00000000..ee2078c2 --- /dev/null +++ b/internal/vm/bindings_msg.go @@ -0,0 +1,37 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !wasm && !plan9 && !rbgo_core + +package vm + +import "github.com/go-embedded-ruby/ruby/internal/object" + +// classOfMsgBinding is the native half of the classOf seam for the NATS, Kafka and +// Parquet bindings, whose Go drivers (nats.go, franz-go, apache/thrift) do not +// build for GOOS=plan9. It handles their value types exactly as the inline cases +// of classOfExtBinding did before they were split out; the plan9 half +// (backends_plan9.go) always reports false, since those types can never be +// constructed there (their register functions are graceful stubs). +func (vm *VM) classOfMsgBinding(v object.Value) (*RClass, bool) { + switch x := v.(type) { + case *NATSX: + return x.cls, true + case *KafkaClient: + return x.cls, true + case *KafkaProducer: + return x.cls, true + case *KafkaConsumer: + return x.cls, true + case *KafkaMessage: + return x.cls, true + case *KafkaBatch: + return x.cls, true + case *ParquetReader: + return vm.consts["Parquet::ArrowFileReader"].(*RClass), true + case *ParquetWriter: + return vm.consts["Parquet::ArrowFileWriter"].(*RClass), true + } + return nil, false +} diff --git a/internal/vm/bindings_native.go b/internal/vm/bindings_native.go index cd86eb4f..230824bc 100644 --- a/internal/vm/bindings_native.go +++ b/internal/vm/bindings_native.go @@ -31,18 +31,6 @@ func (vm *VM) classOfExtBinding(v object.Value) (*RClass, bool) { return vm.consts["GRPC::ActiveCall"].(*RClass), true case *GRPCStatus: return vm.consts["GRPC::Status"].(*RClass), true - case *NATSX: - return x.cls, true - case *KafkaClient: - return x.cls, true - case *KafkaProducer: - return x.cls, true - case *KafkaConsumer: - return x.cls, true - case *KafkaMessage: - return x.cls, true - case *KafkaBatch: - return x.cls, true case *MySQLClient: return x.cls, true case *MySQLResult: @@ -61,10 +49,6 @@ func (vm *VM) classOfExtBinding(v object.Value) (*RClass, bool) { return x.cls, true case *MongoObjectId: return x.cls, true - case *ParquetReader: - return vm.consts["Parquet::ArrowFileReader"].(*RClass), true - case *ParquetWriter: - return vm.consts["Parquet::ArrowFileWriter"].(*RClass), true case *ArrowArray: return vm.consts["Arrow::Array"].(*RClass), true case *ArrowArrayBuilder: @@ -102,6 +86,11 @@ func (vm *VM) classOfExtBinding(v object.Value) (*RClass, bool) { case *OpenStackIdentity: return vm.consts["OpenStack::Identity"].(*RClass), true } + // NATS, Kafka and Parquet are a separate seam: their drivers do not build for + // plan9 (see bindings_msg.go and backends_plan9.go). + if c, ok := vm.classOfMsgBinding(v); ok { + return c, true + } // SQLite3, Sequel, Bolt, Bleve and Etcd are a separate seam: their drivers // build on fewer targets than the rest (see bindings_store.go). return vm.classOfStoreBinding(v) diff --git a/internal/vm/dir.go b/internal/vm/dir.go index 1dac10e8..c0d43cc5 100644 --- a/internal/vm/dir.go +++ b/internal/vm/dir.go @@ -591,12 +591,11 @@ func cwdStillNamed(wd string) error { // not the Errno::ENOENT this used to raise for every failure. Both the class and // the "chdir_path" label were witnessed against MRI 4.0.5. func raiseChdirErr(err error, path string) { - var eno syscall.Errno - if errors.As(err, &eno) { - if name, ok := errnoClasses[int64(eno)]; ok { - raise("Errno::"+name, "%s @ chdir_path - %s", errnoStrerror(int64(eno)), path) + if eno, ok := errnoOfError(err); ok { + if name, ok := errnoClasses[eno]; ok { + raise("Errno::"+name, "%s @ chdir_path - %s", errnoStrerror(eno), path) } - raise("SystemCallError", "%s @ chdir_path - %s", errnoStrerror(int64(eno)), path) + raise("SystemCallError", "%s @ chdir_path - %s", errnoStrerror(eno), path) } raise("Errno::ENOENT", "No such file or directory @ chdir_path - %s", path) } @@ -670,7 +669,7 @@ func raiseRmdirErr(err error, path string) { switch { case os.IsPermission(err): raise("Errno::EACCES", "Permission denied @ dir_s_rmdir - %s", path) - case errors.Is(err, syscall.ENOTEMPTY): + case errIsErrno(err, "ENOTEMPTY"): raise("Errno::ENOTEMPTY", "Directory not empty @ dir_s_rmdir - %s", path) default: raise("Errno::ENOENT", "No such file or directory @ dir_s_rmdir - %s", path) diff --git a/internal/vm/errno.go b/internal/vm/errno.go index 2596bff0..34a996eb 100644 --- a/internal/vm/errno.go +++ b/internal/vm/errno.go @@ -7,7 +7,6 @@ package vm import ( "strconv" "strings" - "syscall" ) // This file holds the Errno name table behind Ruby's Errno::Exxx classes and the @@ -47,105 +46,6 @@ import ( // (errno_native.go / errno_wasm.go), and TestErrnoBucketsPartitionKnownNames // checks the partition on whichever target it runs on. -// errnoUniversalNumbers maps an Errno::Exxx name to its errno number for the -// names EVERY GOOS rbgo builds for defines. The values come from the host's -// syscall table rather than being fixed here, because they genuinely differ -// across platforms (EAGAIN is 11 on Linux and 35 on the BSDs/macOS) and -// Errno::EAGAIN::Errno must match the host MRI's. -// -// "Universal" is measured, not assumed: a probe compiled this set for -// darwin/{amd64,arm64}, linux/{amd64,386,arm64,riscv64,loong64,ppc64le,s390x}, -// windows/{amd64,arm64}, wasip1/wasm and js/wasm, and later for every other -// GOOS Go supports except plan9 (whose syscall package has no errno numbers). The -// names some of those lack (ENOTRECOVERABLE, EOWNERDEAD and ETXTBSY on js/wasm, -// EMULTIHOP and ENOLINK on OpenBSD, ENOTRECOVERABLE and EOWNERDEAD on NetBSD -// and DragonFly) are NOT here — they live in the per-target files, which is what -// issue #682 was about. -// -// The names outside this set are in errnoUndefinedNames, where rbgo reports 0. -// That is a DECLARED conformance gap, not an absence: the same probe found that -// Go's portable syscall package does define many of them per-GOOS with no build -// tag at all (57 of the 81 on linux/amd64, 56 on windows, 31 on darwin/amd64, 2 -// on wasip1 — macOS EAUTH at 80 is only the best-known one). Closing it needs a -// per-platform MRI witness for each number, which is the open per-platform errno -// issue and deliberately not done here. -var errnoUniversalNumbers = map[string]int64{ - "E2BIG": int64(syscall.E2BIG), - "EACCES": int64(syscall.EACCES), - "EADDRINUSE": int64(syscall.EADDRINUSE), - "EADDRNOTAVAIL": int64(syscall.EADDRNOTAVAIL), - "EAFNOSUPPORT": int64(syscall.EAFNOSUPPORT), - "EAGAIN": int64(syscall.EAGAIN), - "EALREADY": int64(syscall.EALREADY), - "EBADF": int64(syscall.EBADF), - "EBADMSG": int64(syscall.EBADMSG), - "EBUSY": int64(syscall.EBUSY), - "ECANCELED": int64(syscall.ECANCELED), - "ECHILD": int64(syscall.ECHILD), - "ECONNABORTED": int64(syscall.ECONNABORTED), - "ECONNREFUSED": int64(syscall.ECONNREFUSED), - "ECONNRESET": int64(syscall.ECONNRESET), - "EDEADLK": int64(syscall.EDEADLK), - "EDESTADDRREQ": int64(syscall.EDESTADDRREQ), - "EDOM": int64(syscall.EDOM), - "EDQUOT": int64(syscall.EDQUOT), - "EEXIST": int64(syscall.EEXIST), - "EFAULT": int64(syscall.EFAULT), - "EFBIG": int64(syscall.EFBIG), - "EHOSTUNREACH": int64(syscall.EHOSTUNREACH), - "EIDRM": int64(syscall.EIDRM), - "EILSEQ": int64(syscall.EILSEQ), - "EINPROGRESS": int64(syscall.EINPROGRESS), - "EINTR": int64(syscall.EINTR), - "EINVAL": int64(syscall.EINVAL), - "EIO": int64(syscall.EIO), - "EISCONN": int64(syscall.EISCONN), - "EISDIR": int64(syscall.EISDIR), - "ELOOP": int64(syscall.ELOOP), - "EMFILE": int64(syscall.EMFILE), - "EMLINK": int64(syscall.EMLINK), - "EMSGSIZE": int64(syscall.EMSGSIZE), - "ENAMETOOLONG": int64(syscall.ENAMETOOLONG), - "ENETDOWN": int64(syscall.ENETDOWN), - "ENETRESET": int64(syscall.ENETRESET), - "ENETUNREACH": int64(syscall.ENETUNREACH), - "ENFILE": int64(syscall.ENFILE), - "ENOBUFS": int64(syscall.ENOBUFS), - "ENODEV": int64(syscall.ENODEV), - "ENOENT": int64(syscall.ENOENT), - "ENOEXEC": int64(syscall.ENOEXEC), - "ENOLCK": int64(syscall.ENOLCK), - "ENOMEM": int64(syscall.ENOMEM), - "ENOMSG": int64(syscall.ENOMSG), - "ENOPROTOOPT": int64(syscall.ENOPROTOOPT), - "ENOSPC": int64(syscall.ENOSPC), - "ENOSYS": int64(syscall.ENOSYS), - "ENOTCONN": int64(syscall.ENOTCONN), - "ENOTDIR": int64(syscall.ENOTDIR), - "ENOTEMPTY": int64(syscall.ENOTEMPTY), - "ENOTSOCK": int64(syscall.ENOTSOCK), - "ENOTSUP": int64(syscall.ENOTSUP), - "ENOTTY": int64(syscall.ENOTTY), - "ENXIO": int64(syscall.ENXIO), - "EOPNOTSUPP": int64(syscall.EOPNOTSUPP), - "EOVERFLOW": int64(syscall.EOVERFLOW), - "EPERM": int64(syscall.EPERM), - "EPIPE": int64(syscall.EPIPE), - "EPROTO": int64(syscall.EPROTO), - "EPROTONOSUPPORT": int64(syscall.EPROTONOSUPPORT), - "EPROTOTYPE": int64(syscall.EPROTOTYPE), - "ERANGE": int64(syscall.ERANGE), - "EROFS": int64(syscall.EROFS), - "ESPIPE": int64(syscall.ESPIPE), - "ESRCH": int64(syscall.ESRCH), - "ESTALE": int64(syscall.ESTALE), - "ETIMEDOUT": int64(syscall.ETIMEDOUT), - "EXDEV": int64(syscall.EXDEV), - // NOERROR is errno 0. MRI registers it first, so every name the platform does - // not define (errnoUndefinedNames) binds to this very class. - "NOERROR": 0, -} - // errnoPlatformNames are the MRI known_errors names Go's portable syscall package // defines on SOME of rbgo's targets and not others — bucket 1 on one GOOS and // bucket 3 on another. js/wasm is the whole reason this bucket exists: Go's @@ -254,10 +154,11 @@ var errnoAliases = map[string]string{"EWOULDBLOCK": "EAGAIN"} // [[:graph:]]+), and settling it properly needs a Linux MRI witness — see the // per-platform errno issue. func errnoStrerror(n int64) string { - if s, ok := errnoUnknownText(syscall.Errno(n).Error(), n); ok { + text := errnoGoText(n) + if s, ok := errnoUnknownText(text, n); ok { return s } - return capitalizeASCII(syscall.Errno(n).Error()) + return capitalizeASCII(text) } // errnoUnknownText recognises Go's "errno N" placeholder — what diff --git a/internal/vm/errno_host.go b/internal/vm/errno_host.go new file mode 100644 index 00000000..326d5646 --- /dev/null +++ b/internal/vm/errno_host.go @@ -0,0 +1,141 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package vm + +import ( + "errors" + "syscall" +) + +// errnoOfError returns the errno number a failed call's error carries: the +// syscall.Errno errors.As digs out of the wrapping (*os.PathError, +// *os.SyscallError, *net.OpError …). ok is false for an error with no errno +// behind it. The Plan 9 counterpart (errno_plan9.go) has no numbers to dig out and +// derives one from the error string instead. +func errnoOfError(err error) (int64, bool) { + var eno syscall.Errno + if errors.As(err, &eno) { + return int64(eno), true + } + return 0, false +} + +// errIsErrno reports whether err is the Errno class `name` (an errnoNumbers key): +// errors.Is against the host's syscall.Errno of that number, so it answers exactly +// what errors.Is(err, syscall.) would. It exists so the call sites compile +// on Plan 9, whose syscall package lacks most of those constants. +func errIsErrno(err error, name string) bool { + return errors.Is(err, syscall.Errno(errnoNumbers[name])) +} + +// errnoText is the message sysFail embeds for an errno: errnoMessage of the host's +// syscall.Errno, i.e. Go's own text with its first letter restored. +func errnoText(eno int64) string { return errnoMessage(syscall.Errno(eno)) } + +// errnoGoText is the text errnoStrerror starts from: Go's own, from +// syscall.Errno.Error(). Plan 9 supplies glibc's instead (errno_plan9.go). +func errnoGoText(n int64) string { return syscall.Errno(n).Error() } + +// errnoUniversalNumbers maps an Errno::Exxx name to its errno number for the +// names EVERY GOOS rbgo builds for (except plan9, see errno_plan9.go) defines. The +// values come from the host's syscall table rather than being fixed here, because +// they genuinely differ across platforms (EAGAIN is 11 on Linux and 35 on the +// BSDs/macOS) and Errno::EAGAIN::Errno must match the host MRI's. +// +// "Universal" is measured, not assumed: a probe compiled this set for +// darwin/{amd64,arm64}, linux/{amd64,386,arm64,riscv64,loong64,ppc64le,s390x}, +// windows/{amd64,arm64}, wasip1/wasm and js/wasm, and later for every other +// GOOS Go supports except plan9 (whose syscall package has no errno numbers). The +// names some of those lack (ENOTRECOVERABLE, EOWNERDEAD and ETXTBSY on js/wasm, +// EMULTIHOP and ENOLINK on OpenBSD, ENOTRECOVERABLE and EOWNERDEAD on NetBSD +// and DragonFly) are NOT here — they live in the per-target files, which is what +// issue #682 was about. +// +// The names outside this set are in errnoUndefinedNames, where rbgo reports 0. +// That is a DECLARED conformance gap, not an absence: the same probe found that +// Go's portable syscall package does define many of them per-GOOS with no build +// tag at all (57 of the 81 on linux/amd64, 56 on windows, 31 on darwin/amd64, 2 +// on wasip1 — macOS EAUTH at 80 is only the best-known one). Closing it needs a +// per-platform MRI witness for each number, which is the open per-platform errno +// issue and deliberately not done here. +var errnoUniversalNumbers = map[string]int64{ + "E2BIG": int64(syscall.E2BIG), + "EACCES": int64(syscall.EACCES), + "EADDRINUSE": int64(syscall.EADDRINUSE), + "EADDRNOTAVAIL": int64(syscall.EADDRNOTAVAIL), + "EAFNOSUPPORT": int64(syscall.EAFNOSUPPORT), + "EAGAIN": int64(syscall.EAGAIN), + "EALREADY": int64(syscall.EALREADY), + "EBADF": int64(syscall.EBADF), + "EBADMSG": int64(syscall.EBADMSG), + "EBUSY": int64(syscall.EBUSY), + "ECANCELED": int64(syscall.ECANCELED), + "ECHILD": int64(syscall.ECHILD), + "ECONNABORTED": int64(syscall.ECONNABORTED), + "ECONNREFUSED": int64(syscall.ECONNREFUSED), + "ECONNRESET": int64(syscall.ECONNRESET), + "EDEADLK": int64(syscall.EDEADLK), + "EDESTADDRREQ": int64(syscall.EDESTADDRREQ), + "EDOM": int64(syscall.EDOM), + "EDQUOT": int64(syscall.EDQUOT), + "EEXIST": int64(syscall.EEXIST), + "EFAULT": int64(syscall.EFAULT), + "EFBIG": int64(syscall.EFBIG), + "EHOSTUNREACH": int64(syscall.EHOSTUNREACH), + "EIDRM": int64(syscall.EIDRM), + "EILSEQ": int64(syscall.EILSEQ), + "EINPROGRESS": int64(syscall.EINPROGRESS), + "EINTR": int64(syscall.EINTR), + "EINVAL": int64(syscall.EINVAL), + "EIO": int64(syscall.EIO), + "EISCONN": int64(syscall.EISCONN), + "EISDIR": int64(syscall.EISDIR), + "ELOOP": int64(syscall.ELOOP), + "EMFILE": int64(syscall.EMFILE), + "EMLINK": int64(syscall.EMLINK), + "EMSGSIZE": int64(syscall.EMSGSIZE), + "ENAMETOOLONG": int64(syscall.ENAMETOOLONG), + "ENETDOWN": int64(syscall.ENETDOWN), + "ENETRESET": int64(syscall.ENETRESET), + "ENETUNREACH": int64(syscall.ENETUNREACH), + "ENFILE": int64(syscall.ENFILE), + "ENOBUFS": int64(syscall.ENOBUFS), + "ENODEV": int64(syscall.ENODEV), + "ENOENT": int64(syscall.ENOENT), + "ENOEXEC": int64(syscall.ENOEXEC), + "ENOLCK": int64(syscall.ENOLCK), + "ENOMEM": int64(syscall.ENOMEM), + "ENOMSG": int64(syscall.ENOMSG), + "ENOPROTOOPT": int64(syscall.ENOPROTOOPT), + "ENOSPC": int64(syscall.ENOSPC), + "ENOSYS": int64(syscall.ENOSYS), + "ENOTCONN": int64(syscall.ENOTCONN), + "ENOTDIR": int64(syscall.ENOTDIR), + "ENOTEMPTY": int64(syscall.ENOTEMPTY), + "ENOTSOCK": int64(syscall.ENOTSOCK), + "ENOTSUP": int64(syscall.ENOTSUP), + "ENOTTY": int64(syscall.ENOTTY), + "ENXIO": int64(syscall.ENXIO), + "EOPNOTSUPP": int64(syscall.EOPNOTSUPP), + "EOVERFLOW": int64(syscall.EOVERFLOW), + "EPERM": int64(syscall.EPERM), + "EPIPE": int64(syscall.EPIPE), + "EPROTO": int64(syscall.EPROTO), + "EPROTONOSUPPORT": int64(syscall.EPROTONOSUPPORT), + "EPROTOTYPE": int64(syscall.EPROTOTYPE), + "ERANGE": int64(syscall.ERANGE), + "EROFS": int64(syscall.EROFS), + "ESPIPE": int64(syscall.ESPIPE), + "ESRCH": int64(syscall.ESRCH), + "ESTALE": int64(syscall.ESTALE), + "ETIMEDOUT": int64(syscall.ETIMEDOUT), + "EXDEV": int64(syscall.EXDEV), + + // NOERROR is errno 0. MRI registers it first, so every name the platform does + // not define (errnoUndefinedNames) binds to this very class. + "NOERROR": 0, +} diff --git a/internal/vm/errno_native.go b/internal/vm/errno_native.go index bbb34047..89319e2b 100644 --- a/internal/vm/errno_native.go +++ b/internal/vm/errno_native.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !(js && wasm) && !openbsd && !netbsd && !dragonfly +//go:build !(js && wasm) && !openbsd && !netbsd && !dragonfly && !plan9 package vm diff --git a/internal/vm/errno_plan9.go b/internal/vm/errno_plan9.go new file mode 100644 index 00000000..201253b9 --- /dev/null +++ b/internal/vm/errno_plan9.go @@ -0,0 +1,69 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build plan9 + +package vm + +import ( + "errors" + "syscall" +) + +// This is the GOOS=plan9 half of the errno bucket rule stated in errno.go. Plan 9 +// has no errno numbers (see errno_plan9_map.go for the whole account), so every +// bucket-1 number is the Linux one, and the five per-target names (EMULTIHOP, +// ENOLINK, ENOTRECOVERABLE, EOWNERDEAD, ETXTBSY) take their Linux numbers too +// rather than becoming undefined: MRI defines every name on every platform, and a +// Plan 9 program has no reason to see fewer classes than a Linux one. + +// errnoUniversalNumbers is the Linux number table (errno_plan9_map.go). +var errnoUniversalNumbers = errnoLinuxUniversal() + +// errnoPlatformNumbers gives the per-target names their Linux numbers. +var errnoPlatformNumbers = errnoLinuxPlatform() + +// errnoPlatformUndefined is empty: every per-target name has a number here. +var errnoPlatformUndefined []string + +// errnoGoText is the text errnoStrerror starts from: the glibc wording of the +// Linux number, since syscall.Errno.Error() prints only "errno N" on Plan 9. A +// number the table does not name keeps that "errno N" form, which errnoStrerror +// replaces with the C library's "Unknown error: N". +func errnoGoText(n int64) string { + if s, ok := errnoLinuxText(n); ok { + return s + } + return syscall.Errno(n).Error() +} + +// errnoText is the message sysFail embeds for an errno: the strerror text, since +// syscall.Errno.Error() prints only "errno N" here. +func errnoText(eno int64) string { return errnoStrerror(eno) } + +// errIsErrno reports whether err is the Errno class `name`, by the same string +// classification errnoOfError uses. +func errIsErrno(err error, name string) bool { + n, ok := errnoOfError(err) + return ok && n == errnoNumbers[name] +} + +// errnoOfError returns the Errno number a failed call's error stands for: the +// number of the class plan9ErrnoName chooses for its text. ok is false for an +// error the Go standard library's rules do not classify, which the caller raises +// as a plain SystemCallError carrying the message. +func errnoOfError(err error) (int64, bool) { + for u := errors.Unwrap(err); u != nil; u = errors.Unwrap(err) { + err = u + } + if err == nil { + return 0, false + } + name, ok := plan9ErrnoName(err.Error()) + if !ok { + return 0, false + } + n, ok := errnoNumbers[name] + return n, ok +} diff --git a/internal/vm/errno_plan9_map.go b/internal/vm/errno_plan9_map.go new file mode 100644 index 00000000..26ab7f09 --- /dev/null +++ b/internal/vm/errno_plan9_map.go @@ -0,0 +1,246 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "strings" + +// This file is the Plan 9 half of the Errno layer, written without a build tag on +// purpose: it is pure Go (strings and tables), so the mapping is unit-tested on +// every platform, while only errno_plan9.go (GOOS=plan9) consumes it. +// +// WHY IT EXISTS. Plan 9 has no errno numbers. Go's plan9 syscall package +// (src/syscall/errors_plan9.go) declares EINVAL, ENOENT, EACCES … as +// syscall.ErrorString values whose text IS the error (EINVAL is "bad arg in system +// call"), declares no E2BIG, EAGAIN, EBADF, ESRCH, ENOTEMPTY …, and its Errno type +// (syscall_plan9.go) is a bare uintptr that only prints "errno N". Every failing +// call returns a string, so a Ruby Errno class can only be chosen by reading the +// string. +// +// MRI has no such platform, but its rule for a name the platform lacks is the one +// followed here for the numbers (error.c, Init_syserr: undefined_error binds the +// name to Errno::NOERROR; set_syserr registers a class per number): every +// Errno::Exxx constant exists on every platform, and where the platform supplies +// no number Errno::Exxx::Errno takes the Linux value, which is what the table +// below carries. The wording of SystemCallError#message is the glibc strerror +// text for that Linux number. Neither the numbers nor the wording have an MRI +// witness on Plan 9 (MRI does not run there); they are the documented fallback. +// +// THE STRING RULES follow the Go standard library's own classification of these +// strings. syscall.ErrorString.Is (syscall_plan9.go:52) answers +// os.ErrPermission for "permission denied", os.ErrExist for "exists" and "is a +// directory", os.ErrNotExist for "does not exist", "not found", "has been +// removed" and "no parent", and errors.ErrUnsupported for "not supported"; the +// os package adds three messages of its own (os/error_plan9.go: "function not +// implemented", "out of range", "cannot allocate memory"). The exact texts of the +// syscall package's own variables are tried first, so the more specific class wins +// (EISDIR for "file is a directory", where the stdlib's substring rule would say +// "exists"); the substring rules then catch kernel and file-server messages that +// merely contain those phrases ("create: file already exists", "fd: file does not +// exist"). Anything else is unknown: the caller raises a plain SystemCallError +// carrying the message. + +// errnoLinuxEntry is one row of the Linux errno table. +type errnoLinuxEntry struct { + name string + num int64 + text string +} + +// errnoLinuxTable is the Linux (asm-generic/errno-base.h and errno.h) number and +// the glibc strerror text of every Errno name a Plan 9 error string can map to, +// plus the rest of the names MRI's known_errors list defines on Linux that Go's +// portable syscall package also defines on every other target (the +// errnoUniversalNumbers set). EAGAIN and EWOULDBLOCK, and ENOTSUP and +// EOPNOTSUPP, are the same number, as on Linux. +var errnoLinuxTable = []errnoLinuxEntry{ + {"NOERROR", 0, "Success"}, + {"EPERM", 1, "Operation not permitted"}, + {"ENOENT", 2, "No such file or directory"}, + {"ESRCH", 3, "No such process"}, + {"EINTR", 4, "Interrupted system call"}, + {"EIO", 5, "Input/output error"}, + {"ENXIO", 6, "No such device or address"}, + {"E2BIG", 7, "Argument list too long"}, + {"ENOEXEC", 8, "Exec format error"}, + {"EBADF", 9, "Bad file descriptor"}, + {"ECHILD", 10, "No child processes"}, + {"EAGAIN", 11, "Resource temporarily unavailable"}, + {"ENOMEM", 12, "Cannot allocate memory"}, + {"EACCES", 13, "Permission denied"}, + {"EFAULT", 14, "Bad address"}, + {"EBUSY", 16, "Device or resource busy"}, + {"EEXIST", 17, "File exists"}, + {"EXDEV", 18, "Invalid cross-device link"}, + {"ENODEV", 19, "No such device"}, + {"ENOTDIR", 20, "Not a directory"}, + {"EISDIR", 21, "Is a directory"}, + {"EINVAL", 22, "Invalid argument"}, + {"ENFILE", 23, "Too many open files in system"}, + {"EMFILE", 24, "Too many open files"}, + {"ENOTTY", 25, "Inappropriate ioctl for device"}, + {"ETXTBSY", 26, "Text file busy"}, + {"EFBIG", 27, "File too large"}, + {"ENOSPC", 28, "No space left on device"}, + {"ESPIPE", 29, "Illegal seek"}, + {"EROFS", 30, "Read-only file system"}, + {"EMLINK", 31, "Too many links"}, + {"EPIPE", 32, "Broken pipe"}, + {"EDOM", 33, "Numerical argument out of domain"}, + {"ERANGE", 34, "Numerical result out of range"}, + {"EDEADLK", 35, "Resource deadlock avoided"}, + {"ENAMETOOLONG", 36, "File name too long"}, + {"ENOLCK", 37, "No locks available"}, + {"ENOSYS", 38, "Function not implemented"}, + {"ENOTEMPTY", 39, "Directory not empty"}, + {"ELOOP", 40, "Too many levels of symbolic links"}, + {"ENOMSG", 42, "No message of desired type"}, + {"EIDRM", 43, "Identifier removed"}, + {"ENOLINK", 67, "Link has been severed"}, + {"EPROTO", 71, "Protocol error"}, + {"EMULTIHOP", 72, "Multihop attempted"}, + {"EBADMSG", 74, "Bad message"}, + {"EOVERFLOW", 75, "Value too large for defined data type"}, + {"EILSEQ", 84, "Invalid or incomplete multibyte or wide character"}, + {"ENOTSOCK", 88, "Socket operation on non-socket"}, + {"EDESTADDRREQ", 89, "Destination address required"}, + {"EMSGSIZE", 90, "Message too long"}, + {"EPROTOTYPE", 91, "Protocol wrong type for socket"}, + {"ENOPROTOOPT", 92, "Protocol not available"}, + {"EPROTONOSUPPORT", 93, "Protocol not supported"}, + {"EOPNOTSUPP", 95, "Operation not supported"}, + {"ENOTSUP", 95, "Operation not supported"}, + {"EAFNOSUPPORT", 97, "Address family not supported by protocol"}, + {"EADDRINUSE", 98, "Address already in use"}, + {"EADDRNOTAVAIL", 99, "Cannot assign requested address"}, + {"ENETDOWN", 100, "Network is down"}, + {"ENETUNREACH", 101, "Network is unreachable"}, + {"ENETRESET", 102, "Network dropped connection on reset"}, + {"ECONNABORTED", 103, "Software caused connection abort"}, + {"ECONNRESET", 104, "Connection reset by peer"}, + {"ENOBUFS", 105, "No buffer space available"}, + {"EISCONN", 106, "Transport endpoint is already connected"}, + {"ENOTCONN", 107, "Transport endpoint is not connected"}, + {"ETIMEDOUT", 110, "Connection timed out"}, + {"ECONNREFUSED", 111, "Connection refused"}, + {"EHOSTUNREACH", 113, "No route to host"}, + {"EALREADY", 114, "Operation already in progress"}, + {"EINPROGRESS", 115, "Operation now in progress"}, + {"ESTALE", 116, "Stale file handle"}, + {"EDQUOT", 122, "Disk quota exceeded"}, + {"ECANCELED", 125, "Operation canceled"}, + {"EOWNERDEAD", 130, "Owner died"}, + {"ENOTRECOVERABLE", 131, "State not recoverable"}, +} + +// errnoLinuxPlatformNames are the errnoPlatformNames (errno.go) the Linux table +// carries; on Plan 9 they take their Linux numbers instead of being undefined. +var errnoLinuxPlatformNames = map[string]bool{ + "EMULTIHOP": true, "ENOLINK": true, "ENOTRECOVERABLE": true, + "EOWNERDEAD": true, "ETXTBSY": true, +} + +// errnoLinuxUniversal returns the Linux numbers of every table row that is not a +// per-target name: the Plan 9 value of errnoUniversalNumbers. +func errnoLinuxUniversal() map[string]int64 { + m := make(map[string]int64, len(errnoLinuxTable)) + for _, e := range errnoLinuxTable { + if !errnoLinuxPlatformNames[e.name] { + m[e.name] = e.num + } + } + return m +} + +// errnoLinuxPlatform returns the Linux numbers of the per-target names. +func errnoLinuxPlatform() map[string]int64 { + m := make(map[string]int64, len(errnoLinuxPlatformNames)) + for _, e := range errnoLinuxTable { + if errnoLinuxPlatformNames[e.name] { + m[e.name] = e.num + } + } + return m +} + +// errnoLinuxText returns the glibc strerror text of Linux errno number n. The +// first row for a number wins, so 95 reads "Operation not supported" for both of +// its names. +func errnoLinuxText(n int64) (string, bool) { + for _, e := range errnoLinuxTable { + if e.num == n { + return e.text, true + } + } + return "", false +} + +// plan9ExactErrors are the texts of the error variables Go's plan9 syscall +// package declares (src/syscall/errors_plan9.go) and of the three the os package +// adds (src/os/error_plan9.go), each with the Errno class of the same name. +// EPLAN9, "not supported by plan 9", has no POSIX namesake; the stdlib classifies +// it as errors.ErrUnsupported, so it takes ENOTSUP. +var plan9ExactErrors = map[string]string{ + "bad arg in system call": "EINVAL", + "not a directory": "ENOTDIR", + "file is a directory": "EISDIR", + "file does not exist": "ENOENT", + "file already exists": "EEXIST", + "no free file descriptors": "EMFILE", + "i/o error": "EIO", + "file name too long": "ENAMETOOLONG", + "interrupted": "EINTR", + "permission denied": "EPERM", + "no free devices": "EBUSY", + "connection timed out": "ETIMEDOUT", + "not supported by plan 9": "ENOTSUP", + "access permission denied": "EACCES", + "address family not supported by protocol": "EAFNOSUPPORT", + "illegal seek": "ESPIPE", + "function not implemented": "ENOSYS", + "out of range": "ERANGE", + "cannot allocate memory": "ENOMEM", + + // plan9ESRCHText is the text of the one sentinel rbgo invents: Plan 9 has no + // ESRCH, so Process.kill on a pid with no /proc entry reports this error + // (spawn_plan9.go) and it reads back as Errno::ESRCH. + plan9ESRCHText: "ESRCH", +} + +// plan9ESRCHText is the message of spawn_plan9.go's "no such process" error. +const plan9ESRCHText = "no such process" + +// plan9ContainsRules are the substring classifications of syscall.ErrorString.Is, +// in the order the stdlib tests them (permission, exist, not-exist, unsupported). +// The order cannot misfire: no not-exist phrase contains "exists". A message that +// matches none of the exact texts or these phrases is unknown. +var plan9ContainsRules = []struct { + phrases []string + name string +}{ + {[]string{"permission denied"}, "EACCES"}, + {[]string{"exists", "is a directory"}, "EEXIST"}, + {[]string{"does not exist", "not found", "has been removed", "no parent"}, "ENOENT"}, + {[]string{"not supported"}, "ENOTSUP"}, +} + +// plan9ErrnoName maps a Plan 9 error string to the Errno name MRI would raise for +// the equivalent POSIX failure, or "" and false for a string the stdlib does not +// classify. The text is trimmed first: Plan 9 file servers return +// "create: file already exists"-style prefixed messages, and wrapped errors end +// in newlines. +func plan9ErrnoName(msg string) (string, bool) { + msg = strings.TrimSpace(msg) + if name, ok := plan9ExactErrors[msg]; ok { + return name, true + } + for _, r := range plan9ContainsRules { + for _, p := range r.phrases { + if strings.Contains(msg, p) { + return r.name, true + } + } + } + return "", false +} diff --git a/internal/vm/errno_plan9_map_internal_test.go b/internal/vm/errno_plan9_map_internal_test.go new file mode 100644 index 00000000..e0800d20 --- /dev/null +++ b/internal/vm/errno_plan9_map_internal_test.go @@ -0,0 +1,244 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "runtime" + "sort" + "testing" +) + +// The Plan 9 errno mapping is pure Go (errno_plan9_map.go, plan9_notes.go, +// plan9_stat.go), so these tests run on every platform: there is no Plan 9 runner +// in CI, and the table is the part that can be wrong. + +// TestPlan9LinuxTablePartitionsKnownNames is the bucket rule of errno.go checked +// against the Plan 9 tables: the Linux universal names, the Linux platform names, +// the aliases and the undefined-everywhere names are disjoint and together are +// MRI's errnoKnownNameCount names, so Errno.constants has the same 158 entries on +// Plan 9 as everywhere else. +func TestPlan9LinuxTablePartitionsKnownNames(t *testing.T) { + seen := map[string]string{} + add := func(name, bucket string) { + if prev, dup := seen[name]; dup { + t.Errorf("%s is in both %s and %s", name, prev, bucket) + } + seen[name] = bucket + } + for name := range errnoLinuxUniversal() { + add(name, "linux universal") + } + for name := range errnoLinuxPlatform() { + add(name, "linux platform") + } + for name := range errnoAliases { + add(name, "aliases") + } + for _, name := range errnoUndefinedEverywhere { + add(name, "undefined everywhere") + } + if len(seen) != errnoKnownNameCount { + t.Errorf("the Plan 9 buckets hold %d names, want MRI's %d", len(seen), errnoKnownNameCount) + } + var plat []string + for name := range errnoLinuxPlatform() { + plat = append(plat, name) + } + want := append([]string{}, errnoPlatformNames...) + sort.Strings(plat) + sort.Strings(want) + if len(plat) != len(want) { + t.Fatalf("Linux platform names %v, want errnoPlatformNames %v", plat, want) + } + for i := range plat { + if plat[i] != want[i] { + t.Fatalf("Linux platform names %v, want errnoPlatformNames %v", plat, want) + } + } +} + +// TestPlan9LinuxTableMatchesHost proves the table against a source that is not +// the table: the set of names must equal the host's errnoUniversalNumbers on every +// platform that has numbers (all but plan9, where this file's table IS the host), +// and on Linux — whose generic errno numbers the table carries — the values must +// agree too. A name missing from the table, or a mistyped number, fails here. +func TestPlan9LinuxTableMatchesHost(t *testing.T) { + if runtime.GOOS == "plan9" { + t.Skip("the table is the host table on plan9") + } + linux := errnoLinuxUniversal() + for name := range errnoUniversalNumbers { + if _, ok := linux[name]; !ok { + t.Errorf("host has %s, the Linux table does not", name) + } + } + for name := range linux { + if _, ok := errnoUniversalNumbers[name]; !ok { + t.Errorf("the Linux table has %s, the host does not", name) + } + } + generic := runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "386" || + runtime.GOARCH == "arm" || runtime.GOARCH == "arm64" || runtime.GOARCH == "riscv64" || + runtime.GOARCH == "loong64" || runtime.GOARCH == "s390x") + if !generic { + return + } + for name, n := range linux { + if name == "EWOULDBLOCK" { + continue + } + if got := errnoUniversalNumbers[name]; got != n { + t.Errorf("%s: Linux table %d, host %d", name, n, got) + } + } + for name, n := range errnoLinuxPlatform() { + if got := errnoPlatformNumbers[name]; got != n { + t.Errorf("%s: Linux table %d, host %d", name, n, got) + } + } +} + +// TestPlan9LinuxText checks the strerror lookup: first row wins for a shared +// number, an unnamed number is not found, and every row has text. +func TestPlan9LinuxText(t *testing.T) { + for _, c := range []struct { + n int64 + want string + }{{22, "Invalid argument"}, {2, "No such file or directory"}, {95, "Operation not supported"}, {0, "Success"}} { + if got, ok := errnoLinuxText(c.n); !ok || got != c.want { + t.Errorf("errnoLinuxText(%d) = %q, %v; want %q", c.n, got, ok, c.want) + } + } + if got, ok := errnoLinuxText(9999); ok { + t.Errorf("errnoLinuxText(9999) = %q, want not found", got) + } + for _, e := range errnoLinuxTable { + if e.text == "" { + t.Errorf("%s has no text", e.name) + } + } +} + +// TestPlan9ErrnoName drives the string classification: every exact text the plan9 +// syscall and os packages declare, the stdlib's substring rules, wrapped and +// padded messages, and strings it does not classify. +func TestPlan9ErrnoName(t *testing.T) { + known := map[string]bool{} + for _, e := range errnoLinuxTable { + known[e.name] = true + } + for msg, want := range map[string]string{ + // src/syscall/errors_plan9.go + "bad arg in system call": "EINVAL", + "not a directory": "ENOTDIR", + "file is a directory": "EISDIR", + "file does not exist": "ENOENT", + "file already exists": "EEXIST", + "no free file descriptors": "EMFILE", + "i/o error": "EIO", + "file name too long": "ENAMETOOLONG", + "interrupted": "EINTR", + "permission denied": "EPERM", + "no free devices": "EBUSY", + "connection timed out": "ETIMEDOUT", + "not supported by plan 9": "ENOTSUP", + "access permission denied": "EACCES", + "address family not supported by protocol": "EAFNOSUPPORT", + "illegal seek": "ESPIPE", + // src/os/error_plan9.go + "function not implemented": "ENOSYS", + "out of range": "ERANGE", + "cannot allocate memory": "ENOMEM", + // rbgo's own sentinel (spawn_plan9.go) + plan9ESRCHText: "ESRCH", + // the stdlib's substring rules (syscall.ErrorString.Is) + "open /x: permission denied": "EACCES", + "create: file already exists now": "EEXIST", + "mkdir: x is a directory": "EEXIST", + "fd: file does not exist": "ENOENT", + "walk: x not found": "ENOENT", + "x has been removed": "ENOENT", + "x no parent": "ENOENT", + "thing not supported here": "ENOTSUP", + // trimmed + " file does not exist\n": "ENOENT", + } { + got, ok := plan9ErrnoName(msg) + if !ok || got != want { + t.Errorf("plan9ErrnoName(%q) = %q, %v; want %q", msg, got, ok, want) + } + if !known[got] { + t.Errorf("plan9ErrnoName(%q) = %s, which is not in the Linux table", msg, got) + } + } + for _, msg := range []string{"", "something else entirely", "errno 5"} { + if got, ok := plan9ErrnoName(msg); ok { + t.Errorf("plan9ErrnoName(%q) = %q, want unclassified", msg, got) + } + } + // Every name the rules can return exists in the Linux table. + for _, name := range plan9ExactErrors { + if !known[name] { + t.Errorf("exact rule names %s, not in the Linux table", name) + } + } + for _, r := range plan9ContainsRules { + if !known[r.name] { + t.Errorf("contains rule names %s, not in the Linux table", r.name) + } + } +} + +// TestPlan9Notes checks the signal-to-note table: each entry is a real signal name, +// the six notes are Go's, and a signal without a note is refused. +func TestPlan9Notes(t *testing.T) { + for name := range plan9Notes { + if _, ok := signalNumbers[name]; !ok { + t.Errorf("plan9Notes names %s, which is not a Ruby signal", name) + } + } + for name, want := range map[string]string{ + "KILL": "kill", "INT": "interrupt", "HUP": "hangup", + "ALRM": "alarm", "ABRT": "abort", "TERM": "interrupt", + } { + note, ok := plan9NoteFor(signalNumbers[name]) + if !ok || note != want { + t.Errorf("plan9NoteFor(%s) = %q, %v; want %q", name, note, ok, want) + } + } + if note, ok := plan9NoteFor(signalNumbers["USR1"]); ok { + t.Errorf("USR1 has note %q, want none", note) + } + if note, ok := plan9NoteFor(0); ok { + t.Errorf("signal 0 has note %q, want none", note) + } +} + +// TestPlan9StatFields checks the Dir-to-stat mapping and the owner comparison. +func TestPlan9StatFields(t *testing.T) { + f := plan9StatFields(0x1234, 0x5, 0x6) + if f.ino != 0x1234 || f.dev != 0x5<<32|0x6 || f.nlink != 1 || !f.hasSys || f.uid != 0 || f.gid != 0 { + t.Errorf("plan9StatFields = %+v", f) + } + // A Qid path above the int64 range wraps, but is stable and not an error. + if big := plan9StatFields(^uint64(0), 0xffff, 0xffffffff); big.ino != -1 || big.dev != 0xffff<<32|0xffffffff { + t.Errorf("plan9StatFields(max) = %+v", big) + } + for _, c := range []struct { + owner, user string + want bool + }{ + {"glenda", "glenda", true}, + {"glenda", "glenda\n", true}, + {"glenda", "bootes", false}, + {"", "", false}, + {"glenda", "", false}, + {"", "glenda", false}, + } { + if got := plan9Owner(c.owner, c.user); got != c.want { + t.Errorf("plan9Owner(%q, %q) = %v, want %v", c.owner, c.user, got, c.want) + } + } +} diff --git a/internal/vm/filestat_plan9.go b/internal/vm/filestat_plan9.go new file mode 100644 index 00000000..ce81af17 --- /dev/null +++ b/internal/vm/filestat_plan9.go @@ -0,0 +1,86 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build plan9 + +package vm + +import ( + "errors" + "io/fs" + "os" + "sync" + "syscall" + "time" +) + +// This is the GOOS=plan9 counterpart of filestat_unix.go. Plan 9 has no +// syscall.Stat_t: os.Stat fills os.FileInfo.Sys() with a *syscall.Dir +// (src/os/stat_plan9.go), which carries a server type and subtype, a Qid and the +// owner names as STRINGS. + +// chrootFn is unsupported: Plan 9 has no chroot(2) (it has per-process namespaces, +// which Dir.chroot does not model), so the call reports the operation unsupported. +var chrootFn = func(string) error { return errors.ErrUnsupported } + +// lutimesFn sets a file's access and modification times. Plan 9 has no symbolic +// links, so "without following a final link" is the same operation as +// utime: os.Chtimes (a wstat of the Dir's Atime and Mtime). +var lutimesFn = func(path string, atime, mtime int64) error { + return os.Chtimes(path, time.Unix(atime, 0), time.Unix(mtime, 0)) +} + +// statSys extracts the stat fields from the *syscall.Dir os.FileInfo carries; see +// plan9StatFields for the mapping. A FileInfo whose Sys() is some other type gets +// the defaults. +func statSys(fi fs.FileInfo) statFields { + d, ok := fi.Sys().(*syscall.Dir) + if !ok { + return statFields{nlink: 1} + } + return plan9StatFields(d.Qid.Path, d.Type, d.Dev) +} + +// lockFdFn, flockFn and closeFdFn are the File#flock seam. Plan 9 has no flock(2) +// (its locking is the exclusive-use bit of a file's mode, a different model), so +// File#flock raises NotImplementedError, as MRI does where the call is missing +// (rb_f_notimplement). The lock descriptor is requested first, so the error is +// raised before anything is opened. +var ( + lockFdFn = func(string) (int, error) { + notImplemented("flock") + return 0, nil + } + flockFn = func(int, int) error { return errors.ErrUnsupported } + closeFdFn = func(int) error { return nil } +) + +// flockAgainErrs is empty: with no flock(2) there is no "would block" to retry on. +var flockAgainErrs []error + +// plan9User is the current user's name, read once from /dev/user (the file Go's own +// os/user reads on plan9, src/os/user/lookup_plan9.go). A read failure leaves it +// empty, which plan9Owner never matches. +var plan9User = sync.OnceValue(func() string { + b, err := os.ReadFile("/dev/user") + if err != nil { + return "" + } + return string(b) +}) + +// statOwned / statGrpowned compare the Dir's owner and group NAMES with the +// current user: Plan 9 has no numeric ids to compare, and a user is also the group +// of the same name. statPerm is the mode's permission bits, unaltered. +func statOwned(s *FileStat) bool { + d, ok := s.fi.Sys().(*syscall.Dir) + return ok && plan9Owner(d.Uid, plan9User()) +} + +func statGrpowned(s *FileStat) bool { + d, ok := s.fi.Sys().(*syscall.Dir) + return ok && plan9Owner(d.Gid, plan9User()) +} + +func statPerm(fi fs.FileInfo) int64 { return int64(fi.Mode().Perm()) } diff --git a/internal/vm/filestat_posix.go b/internal/vm/filestat_posix.go index c5246cc9..bdb92ecf 100644 --- a/internal/vm/filestat_posix.go +++ b/internal/vm/filestat_posix.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows +//go:build !windows && !plan9 package vm diff --git a/internal/vm/filestat_unix.go b/internal/vm/filestat_unix.go index 7d536afd..983d8ec2 100644 --- a/internal/vm/filestat_unix.go +++ b/internal/vm/filestat_unix.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm +//go:build !windows && !wasm && !plan9 package vm diff --git a/internal/vm/fileutils.go b/internal/vm/fileutils.go index 8b46e096..8e0d74cf 100644 --- a/internal/vm/fileutils.go +++ b/internal/vm/fileutils.go @@ -10,7 +10,6 @@ import ( "os" "path/filepath" "strings" - "syscall" "time" "github.com/go-embedded-ruby/ruby/internal/object" @@ -393,12 +392,11 @@ func (w *realpathWalk) walk(names []string, fallback string, last bool) *realpat // (ENOTDIR on a path that walks through a regular file, EACCES on an unreadable // directory), falling back to the generic SystemCallError for anything else. func realpathSyscallErr(err error, testpath string) *realpathErr { - var eno syscall.Errno - if errors.As(err, &eno) { - if name, ok := errnoClasses[int64(eno)]; ok { - return &realpathErr{"Errno::" + name, errnoStrerror(int64(eno)), testpath} + if eno, ok := errnoOfError(err); ok { + if name, ok := errnoClasses[eno]; ok { + return &realpathErr{"Errno::" + name, errnoStrerror(eno), testpath} } - return &realpathErr{"SystemCallError", errnoStrerror(int64(eno)), testpath} + return &realpathErr{"SystemCallError", errnoStrerror(eno), testpath} } return &realpathErr{"Errno::ENOENT", "No such file or directory", testpath} } diff --git a/internal/vm/io.go b/internal/vm/io.go index 511432fd..b142aea4 100644 --- a/internal/vm/io.go +++ b/internal/vm/io.go @@ -1128,7 +1128,7 @@ func flockWouldBlock(err error) bool { // without flock(2) gives. func raiseFlockErrno(err error, p string) { switch { - case errors.Is(err, syscall.EBADF): + case errIsErrno(err, "EBADF"): raise("Errno::EBADF", "Bad file descriptor @ rb_file_flock - %s", p) case errors.Is(err, syscall.EINVAL): raise("Errno::EINVAL", "Invalid argument @ rb_file_flock - %s", p) diff --git a/internal/vm/kafka.go b/internal/vm/kafka.go index 1a928cd5..d2e648ee 100644 --- a/internal/vm/kafka.go +++ b/internal/vm/kafka.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/kafka_bind.go b/internal/vm/kafka_bind.go index d9435d26..39d18018 100644 --- a/internal/vm/kafka_bind.go +++ b/internal/vm/kafka_bind.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/kafka_bind_internal_test.go b/internal/vm/kafka_bind_internal_test.go index df7b760d..26baed0c 100644 --- a/internal/vm/kafka_bind_internal_test.go +++ b/internal/vm/kafka_bind_internal_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/kafka_test.go b/internal/vm/kafka_test.go index eea0774a..a7d61f99 100644 --- a/internal/vm/kafka_test.go +++ b/internal/vm/kafka_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm_test diff --git a/internal/vm/nats.go b/internal/vm/nats.go index b0cd3c25..9e2edc97 100644 --- a/internal/vm/nats.go +++ b/internal/vm/nats.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/nats_bind.go b/internal/vm/nats_bind.go index 00792774..ad6f4e00 100644 --- a/internal/vm/nats_bind.go +++ b/internal/vm/nats_bind.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/nats_bind_internal_test.go b/internal/vm/nats_bind_internal_test.go index 62de8165..17eda49a 100644 --- a/internal/vm/nats_bind_internal_test.go +++ b/internal/vm/nats_bind_internal_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core && !aix && !(freebsd && (386 || arm)) +//go:build !wasm && !plan9 && !rbgo_core && !aix && !(freebsd && (386 || arm)) package vm diff --git a/internal/vm/parquet.go b/internal/vm/parquet.go index 8152aeb1..2cbccf49 100644 --- a/internal/vm/parquet.go +++ b/internal/vm/parquet.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/parquet_bind.go b/internal/vm/parquet_bind.go index f91bd5fb..25c65ed0 100644 --- a/internal/vm/parquet_bind.go +++ b/internal/vm/parquet_bind.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/parquet_bind_internal_test.go b/internal/vm/parquet_bind_internal_test.go index edc61925..acd11b8f 100644 --- a/internal/vm/parquet_bind_internal_test.go +++ b/internal/vm/parquet_bind_internal_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/parquet_test.go b/internal/vm/parquet_test.go index 5f345104..cfb8d77c 100644 --- a/internal/vm/parquet_test.go +++ b/internal/vm/parquet_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm_test diff --git a/internal/vm/plan9_notes.go b/internal/vm/plan9_notes.go new file mode 100644 index 00000000..83e3d497 --- /dev/null +++ b/internal/vm/plan9_notes.go @@ -0,0 +1,33 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +// plan9Notes maps a Ruby signal name (SIG prefix removed) to the Plan 9 note that +// stands for it. Plan 9 has no signal numbers: a process is signalled by writing a +// note (a short string) to /proc//note, which is what Go's own +// (*os.Process).Signal does on plan9 (src/os/exec_plan9.go, writeProcFile("note", +// sig.String())). The table is the one Go's plan9 syscall package declares +// (src/syscall/errors_plan9.go): SIGABRT "abort", SIGALRM "alarm", SIGHUP +// "hangup", SIGINT "interrupt", SIGKILL "kill", and SIGTERM, which Go also maps to +// "interrupt". Any other signal has no note, and Process.kill with it fails with +// Errno::EINVAL. +// +// It lives in a tag-free file because it is pure data: the mapping is tested on +// every platform, while only spawn_plan9.go (GOOS=plan9) writes the note. +var plan9Notes = map[string]string{ + "ABRT": "abort", + "ALRM": "alarm", + "HUP": "hangup", + "INT": "interrupt", + "KILL": "kill", + "TERM": "interrupt", +} + +// plan9NoteFor returns the note for signal number sig, resolved through the +// canonical signal name (signalNames), and false for a signal Plan 9 cannot send. +func plan9NoteFor(sig int) (string, bool) { + note, ok := plan9Notes[signalNames[sig]] + return note, ok +} diff --git a/internal/vm/plan9_stat.go b/internal/vm/plan9_stat.go new file mode 100644 index 00000000..61b231e3 --- /dev/null +++ b/internal/vm/plan9_stat.go @@ -0,0 +1,38 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "strings" + +// plan9StatFields builds the stat numbers File::Stat reports from the Dir fields +// a Plan 9 stat carries (syscall.Dir: a server Type and Dev, and a Qid whose Path +// is unique within the server). Plan 9 has no inode, device or link-count model of +// the POSIX kind, so: +// +// - ino is the Qid path, the server's unique id for the file; +// - dev packs the server type (high 32 bits) over its subtype (low 32 bits); +// - nlink is 1 (there are no hard links); +// - uid and gid are 0: owners are NAMES (Dir.Uid, Dir.Gid), compared by name in +// filestat_plan9.go's statOwned and statGrpowned rather than by number; +// - blksize, rdev and blocks are 0, which File::Stat reports as such. +// +// It takes plain integers so the mapping is tested on every platform; only +// filestat_plan9.go (GOOS=plan9) has a *syscall.Dir to read them from. +func plan9StatFields(qidPath uint64, typ uint16, dev uint32) statFields { + return statFields{ + ino: int64(qidPath), + dev: int64(typ)<<32 | int64(dev), + nlink: 1, + hasSys: true, + } +} + +// plan9Owner reports whether a Dir owner name is the current user. /dev/user holds +// the user name with no newline; an empty name on either side is never a match, so +// a failed read of /dev/user reports "not owned" rather than "owned". +func plan9Owner(owner, user string) bool { + user = strings.TrimSpace(user) + return owner != "" && owner == user +} diff --git a/internal/vm/process.go b/internal/vm/process.go index 00f06f25..8c222d0b 100644 --- a/internal/vm/process.go +++ b/internal/vm/process.go @@ -5,13 +5,11 @@ package vm import ( - "errors" "math" "math/big" "os" "sort" "strings" - "syscall" "time" "unicode" @@ -494,12 +492,11 @@ var errnoClasses = func() map[int64]string { // exotic failure is still a rescuable SystemCallError rather than a Go panic. func sysFail(err error, op string) { cls, msg := "SystemCallError", errnoMessage(err) - var eno syscall.Errno - if errors.As(err, &eno) { + if eno, ok := errnoOfError(err); ok { // 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 { + msg = errnoText(eno) + if name, ok := errnoClasses[eno]; ok { cls = "Errno::" + name } } @@ -556,12 +553,12 @@ func getpriorityFailed(prio int, err error) bool { if prio != -1 { return true } - var eno syscall.Errno - if !errors.As(err, &eno) { + eno, ok := errnoOfError(err) + if !ok { return false } switch eno { - case syscall.ESRCH, syscall.EINVAL, syscall.EACCES, syscall.EPERM: + case errnoNumbers["ESRCH"], errnoNumbers["EINVAL"], errnoNumbers["EACCES"], errnoNumbers["EPERM"]: return true } return false diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go index ab2517da..e7619ae1 100644 --- a/internal/vm/process_limits_internal_test.go +++ b/internal/vm/process_limits_internal_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm +//go:build !windows && !wasm && !plan9 package vm diff --git a/internal/vm/pstore_lock_plan9.go b/internal/vm/pstore_lock_plan9.go new file mode 100644 index 00000000..c809e497 --- /dev/null +++ b/internal/vm/pstore_lock_plan9.go @@ -0,0 +1,15 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build plan9 + +package vm + +// flockFile is a no-op on Plan 9: there is no flock(2) (syscall.Flock is +// undefined), so there is no advisory lock to take. PStore still works correctly +// single-process; only the cross-process serialisation is skipped, as on Windows +// (pstore_lock_windows.go) and wasm. +func flockFile(path string, readOnly bool) (func(), error) { + return func() {}, nil +} diff --git a/internal/vm/pstore_lock_unix.go b/internal/vm/pstore_lock_unix.go index 6b875382..416a5295 100644 --- a/internal/vm/pstore_lock_unix.go +++ b/internal/vm/pstore_lock_unix.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm +//go:build !windows && !wasm && !plan9 package vm diff --git a/internal/vm/pstore_lock_unix_test.go b/internal/vm/pstore_lock_unix_test.go index 7355535a..6cf76b0b 100644 --- a/internal/vm/pstore_lock_unix_test.go +++ b/internal/vm/pstore_lock_unix_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm && !rbgo_core +//go:build !windows && !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/resque_test.go b/internal/vm/resque_test.go index 5f748aaa..ef710c1b 100644 --- a/internal/vm/resque_test.go +++ b/internal/vm/resque_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !rbgo_core +//go:build !plan9 && !rbgo_core package vm_test diff --git a/internal/vm/rlimit_full.go b/internal/vm/rlimit_full.go index f08cf294..d36125a9 100644 --- a/internal/vm/rlimit_full.go +++ b/internal/vm/rlimit_full.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm && !openbsd && !aix && !solaris +//go:build !windows && !wasm && !openbsd && !aix && !solaris && !plan9 package vm diff --git a/internal/vm/rq_internal_test.go b/internal/vm/rq_internal_test.go new file mode 100644 index 00000000..eca3e7b9 --- /dev/null +++ b/internal/vm/rq_internal_test.go @@ -0,0 +1,11 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "strconv" + +// rq renders an OS path as a Ruby double-quoted string literal (paths here are +// plain ASCII temp dirs, so Go's quoting matches Ruby's). +func rq(p string) string { return strconv.Quote(slash(p)) } diff --git a/internal/vm/sidekiq_bind_internal_test.go b/internal/vm/sidekiq_bind_internal_test.go index 84bbe0b2..d06be9b2 100644 --- a/internal/vm/sidekiq_bind_internal_test.go +++ b/internal/vm/sidekiq_bind_internal_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !wasm && !rbgo_core +//go:build !wasm && !plan9 && !rbgo_core package vm diff --git a/internal/vm/sidekiq_test.go b/internal/vm/sidekiq_test.go index 7e55f50f..758689c1 100644 --- a/internal/vm/sidekiq_test.go +++ b/internal/vm/sidekiq_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !rbgo_core +//go:build !plan9 && !rbgo_core package vm_test diff --git a/internal/vm/socket_dialerr.go b/internal/vm/socket_dialerr.go index 52e06bef..50a7c097 100644 --- a/internal/vm/socket_dialerr.go +++ b/internal/vm/socket_dialerr.go @@ -44,15 +44,14 @@ func raiseDialErr(err error, addr string) { if errors.As(err, &dns) { raise("Socket::ResolutionError", "getaddrinfo: %s", dns.Err) } - var eno syscall.Errno - if errors.As(err, &eno) { + if eno, ok := errnoOfError(err); ok { // Windows fails socket calls with WSAE* codes in the 10000 range, which // errnoClasses does not name -- so a refused connection arrived as a bare // SystemCallError there while darwin and linux were green, and `rescue // Errno::ECONNREFUSED` matched nothing on the one platform nobody tested // it on. MRI translates the same way (rb_w32_map_errno); posixErrno is // the identity everywhere else. - eno = posixErrno(eno) + eno = int64(posixErrno(syscall.Errno(eno))) // An empty addr means the caller has no single peer to name -- the // Net::HTTP transport, where the phase may be a read rather than a // connect and Go's own text already carries the address. Naming @@ -61,10 +60,10 @@ func raiseDialErr(err error, addr string) { if addr != "" { suffix = " - connect(2) for " + addr } - if name, ok := errnoClasses[int64(eno)]; ok { - raise("Errno::"+name, "%s%s", errnoStrerror(int64(eno)), suffix) + if name, ok := errnoClasses[eno]; ok { + raise("Errno::"+name, "%s%s", errnoStrerror(eno), suffix) } - raise("SystemCallError", "%s%s", errnoStrerror(int64(eno)), suffix) + raise("SystemCallError", "%s%s", errnoStrerror(eno), suffix) } // Anything with no errno and no resolution failure behind it keeps the old // class. Falling through to SocketError is deliberate: inventing an errno diff --git a/internal/vm/spawn_native.go b/internal/vm/spawn_native.go index c52b09e1..e7fbff1d 100644 --- a/internal/vm/spawn_native.go +++ b/internal/vm/spawn_native.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm +//go:build !windows && !wasm && !plan9 package vm diff --git a/internal/vm/spawn_plan9.go b/internal/vm/spawn_plan9.go new file mode 100644 index 00000000..2be722ed --- /dev/null +++ b/internal/vm/spawn_plan9.go @@ -0,0 +1,171 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build plan9 + +package vm + +import ( + "os" + "os/exec" + "strconv" + "syscall" +) + +// This is the GOOS=plan9 counterpart of spawn_native.go. Plan 9 has /bin/rc, not +// /bin/sh, so a one-string command line goes through `rc -c`; resource limits, +// process groups, sessions and priorities do not exist and raise +// NotImplementedError, as MRI does where the call is missing (rb_f_notimplement: +// "() function is unimplemented on this machine"); and Process.kill writes a +// note to /proc//note through the Go os package. + +// runCaptured runs cmd, returning its combined output and exit code (see +// spawn_native.go for the contract). A one-string command with metacharacters goes +// through rc. +var runCaptured = func(cmd []string) (string, int) { + if len(cmd) == 0 { + return "", 127 + } + var c *exec.Cmd + if s, sh := shellish(cmd); sh { + c = exec.Command("/bin/rc", "-c", s) + } else { + c = exec.Command(cmd[0], cmd[1:]...) + } + out, err := c.CombinedOutput() + return string(out), exitCodeOf(err) +} + +// systemCommand runs cmd for Kernel#system (see spawn_native.go). +var systemCommand = func(cmd []string) (out string, status int, spawned bool) { + if len(cmd) == 0 { + return "", 127, false + } + var c *exec.Cmd + if s, sh := shellish(cmd); sh { + c = exec.Command("/bin/rc", "-c", s) + } else { + c = exec.Command(cmd[0], cmd[1:]...) + } + b, err := c.CombinedOutput() + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + return "", 127, false + } + } + return string(b), exitCodeOf(err), true +} + +// exitCodeOf extracts a process exit code from exec's error (see spawn_native.go). +func exitCodeOf(err error) int { + if err == nil { + return 0 + } + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + return 127 +} + +// procPosix is true so Process.kill is defined; every other member of the POSIX +// surface raises NotImplementedError through the seams below. +const procPosix = true + +// rlimitResources names the six resources every POSIX system has so that +// Process.getrlimit(:CORE) gets past the name lookup and reaches the seam that +// raises NotImplementedError (an empty table would raise ArgumentError "invalid +// resource name" first). The numbers are placeholders no call ever uses: Plan 9 +// has no resource limits. rlimitValues and prioTargets stay empty. +var ( + rlimitResources = map[string]int{"CORE": 0, "CPU": 1, "DATA": 2, "FSIZE": 3, "NOFILE": 4, "STACK": 5} + rlimitValues = map[string]uint64{} + prioTargets = map[string]int{} +) + +// notImplemented raises the NotImplementedError MRI's rb_f_notimplement does for a +// call the platform lacks. +func notImplemented(name string) { + raise("NotImplementedError", "%s() function is unimplemented on this machine", name) +} + +var ( + procGetrlimit = func(int) (cur, max uint64, err error) { + notImplemented("getrlimit") + return 0, 0, nil + } + procSetrlimit = func(int, uint64, uint64) error { + notImplemented("setrlimit") + return nil + } + procGetpriority = func(int, int) (int, error) { + notImplemented("getpriority") + return 0, nil + } + procSetpriority = func(int, int, int) error { + notImplemented("setpriority") + return nil + } + procGetpgid = func(int) (int, error) { + notImplemented("getpgid") + return 0, nil + } + procSetpgid = func(int, int) error { + notImplemented("setpgid") + return nil + } + procGetsid = func(int) (int, error) { + notImplemented("getsid") + return 0, nil + } + // procRusage reports no CPU time: Plan 9 has no getrusage. + procRusage = func() (utime, stime float64) { return 0, 0 } +) + +// errNoSuchProcess is the error Process.kill reports for a pid with no /proc entry. +// Plan 9 has no ESRCH; plan9ErrnoName reads this text back as Errno::ESRCH. +var errNoSuchProcess = syscall.NewError(plan9ESRCHText) + +// procKill delivers signal number sig to pid as a Plan 9 note +// (/proc//note, written by os.(*Process).Signal). Signal 0 only checks that +// /proc/ exists, as kill(pid, 0) does. A signal with no note, and a negative +// pid (Plan 9 has no process groups to address), fail with Errno::EINVAL. +var procKill = func(pid, sig int) error { + if pid < 0 { + return syscall.EINVAL + } + if sig == 0 { + if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err != nil { + return errNoSuchProcess + } + return nil + } + note, ok := plan9NoteFor(sig) + if !ok { + return syscall.EINVAL + } + p, err := os.FindProcess(pid) + if err != nil { + return errNoSuchProcess + } + if err := p.Signal(syscall.Note(note)); err != nil { + if os.IsNotExist(err) { + return errNoSuchProcess + } + return err + } + return nil +} + +// runSpawnProc runs one prepared child to completion (see spawn_native.go). +var runSpawnProc = func(r *spawnReq) int { + var c *exec.Cmd + if r.shell != "" { + c = exec.Command("/bin/rc", "-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()) +} diff --git a/internal/vm/sysflock_unix.go b/internal/vm/sysflock_unix.go index 84e318ea..20c10d0b 100644 --- a/internal/vm/sysflock_unix.go +++ b/internal/vm/sysflock_unix.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows && !wasm && !aix +//go:build !windows && !wasm && !aix && !plan9 package vm diff --git a/internal/vm/umask_plan9.go b/internal/vm/umask_plan9.go new file mode 100644 index 00000000..20a0b9f5 --- /dev/null +++ b/internal/vm/umask_plan9.go @@ -0,0 +1,13 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build plan9 + +package vm + +// osUmask is the Plan 9 counterpart of the Unix syscall.Umask: Plan 9 has no umask +// (syscall.Umask is undefined there), so File.umask is a no-op that reports a +// conventional 0o022 mask without changing any process state, exactly as on +// Windows (umask_windows.go). +func osUmask(mask int) int { _ = mask; return 0o022 } diff --git a/internal/vm/umask_unix.go b/internal/vm/umask_unix.go index 15548506..957dd0e2 100644 --- a/internal/vm/umask_unix.go +++ b/internal/vm/umask_unix.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: BSD-3-Clause -//go:build !windows +//go:build !windows && !plan9 package vm diff --git a/internal/vm/wave19_filedir_internal_test.go b/internal/vm/wave19_filedir_internal_test.go index cd22cda7..fde4079c 100644 --- a/internal/vm/wave19_filedir_internal_test.go +++ b/internal/vm/wave19_filedir_internal_test.go @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +//go:build !plan9 + package vm import ( @@ -10,17 +12,12 @@ import ( "os" "path/filepath" "runtime" - "strconv" "syscall" "testing" "github.com/go-embedded-ruby/ruby/internal/object" ) -// rq renders an OS path as a Ruby double-quoted string literal (paths here are -// plain ASCII temp dirs, so Go's quoting matches Ruby's). -func rq(p string) string { return strconv.Quote(slash(p)) } - // TestRaiseRmdirErrClassification covers every branch of raiseRmdirErr with // synthetic os errors (independent of the running user's privileges). It reuses // the shared expectRaise helper, which returns the raised RubyError's class. diff --git a/internal/vm/wave28_flock_internal_test.go b/internal/vm/wave28_flock_internal_test.go index 68c8e962..6509415d 100644 --- a/internal/vm/wave28_flock_internal_test.go +++ b/internal/vm/wave28_flock_internal_test.go @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +//go:build !plan9 + package vm import ( diff --git a/internal/vm/wave28_open_paths_internal_test.go b/internal/vm/wave28_open_paths_internal_test.go index 928fab41..3134420a 100644 --- a/internal/vm/wave28_open_paths_internal_test.go +++ b/internal/vm/wave28_open_paths_internal_test.go @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +//go:build !plan9 + package vm import (