diff --git a/internal/vm/io.go b/internal/vm/io.go index a808bb66..ce7d7600 100644 --- a/internal/vm/io.go +++ b/internal/vm/io.go @@ -54,20 +54,28 @@ func ioPathString(o *IOObj) (string, bool) { // in-memory byte buffer with a read/write cursor. The two share the write path // so puts/print/printf/<< work uniformly, and StringIO adds the read methods. type IOObj struct { - cls *RClass // IO, StringIO or File — so classOf/is_a? are exact - w io.Writer - buf []byte // StringIO / File content (buffered in memory) - pos int // read/write cursor - isStr bool // buffer-backed (StringIO / File) vs writer-backed (real IO) - sync bool - closed bool - label string // "STDOUT"/"STDERR"/"STDIN" for inspect - path string // backing file path for a File stream (else "") - writable bool // a File opened for writing — flush the buffer on flush/close - lineno int // #lineno — advanced by each successful line read (gets/readline) - rdClosed bool // #close_read (or a write-only mode) — reads raise "not opened for reading" - wrClosed bool // #close_write (or a read-only mode) — writes raise "not opened for writing" - appendMode bool // opened in append mode ("a"/"a+") — every write lands at end-of-buffer + cls *RClass // IO, StringIO or File — so classOf/is_a? are exact + w io.Writer + buf []byte // StringIO / File content (buffered in memory) + pos int // read/write cursor + isStr bool // buffer-backed (StringIO / File) vs writer-backed (real IO) + sync bool + closed bool + label string // "STDOUT"/"STDERR"/"STDIN" for inspect + path string // backing file path for a File stream (else "") + writable bool // a File opened for writing — flush the buffer on flush/close + lineno int // #lineno — advanced by each successful line read (gets/readline) + rdClosed bool // #close_read (or a write-only mode) — reads raise "not opened for reading" + wrClosed bool // #close_write (or a read-only mode) — writes raise "not opened for writing" + appendMode bool // opened in append mode ("a"/"a+") — every write lands at end-of-buffer + openMode string // the fopen-style access mode a file stream was opened with ("r", "w+", "ab"…) + nonblock bool // O_NONBLOCK is set (io/nonblock): false for a file, true for a pipe end + // encSet records that #set_encoding has resolved this stream's encodings + // against the defaults in force at that moment (io.c io_encoding_set → + // rb_io_ext_int_to_enc). An empty extEnc then means "no encoding" — MRI's + // fptr->encs.enc == NULL — rather than "fall back to Encoding.default_external", + // so a later change to the defaults cannot move it. + encSet bool extEnc string // external encoding name, "" ⇒ Encoding.default_external intEnc string // internal encoding name, "" ⇒ none (nil) fd int // synthetic file descriptor for #fileno (0 ⇒ not yet assigned) @@ -100,13 +108,42 @@ type IOObj struct { pipeSynced int // bytes of pipe.data already folded into this reader's buf isWriteEnd bool reopened *IOObj + + // duplex marks MRI's FMODE_DUPLEX: a stream with independent read and write + // halves, which only IO.popen in a "+" mode creates. It is what #close_read / + // #close_write mean by "duplexed" — a File opened "r+" is readable AND writable + // but NOT duplex, and shutting one of its halves is an error. + duplex bool + + // popen marks a stream IO.popen created and records the child it ran. That + // child has already finished — the process model above is synchronous — so buf + // holds its whole output; bytes written to the stream are kept in popen.stdin, + // out of the buffer being read, and cannot reach a child that is already gone. + popen *popenProc } -// pipeBuf is the shared byte channel behind an IO.pipe reader/writer pair. +// pipeBuf is the shared byte channel behind an IO.pipe reader/writer pair. Each +// end records its own closing: wClosed is EOF for the reader, and rClosed is the +// broken pipe a further write reports (Errno::EPIPE). type pipeBuf struct { data []byte rpos int wClosed bool + rClosed bool +} + +// pipeEndClosed records the closing of one end of a pipe on the shared buffer, so +// the other end sees what closing it means: a closed write end is end-of-file for +// the reader, and a closed read end makes a further write a broken pipe. +func (o *IOObj) pipeEndClosed() { + if o.pipe == nil { + return + } + if o.isWriteEnd { + o.pipe.wClosed = true + return + } + o.pipe.rClosed = true } func (o *IOObj) ToS() string { @@ -131,9 +168,23 @@ func (o *IOObj) writeBytes(p []byte) int { o = cur } if o.pipe != nil && o.isWriteEnd { + // Writing a pipe whose read end has gone is a broken pipe. MRI 4.0.5 + // reports Errno::EPIPE "Broken pipe" and does not die from SIGPIPE, which + // is what core/io/shared/write.rb asserts for #write, #syswrite and + // #write_nonblock alike. + if o.pipe.rClosed { + raise("Errno::EPIPE", "Broken pipe") + } o.pipe.data = append(o.pipe.data, p...) return len(p) } + // A stream from IO.popen buffers the child's whole output; a write must not + // land in it. See popenProc: the child has already finished, so the bytes are + // kept and counted but go nowhere. + if o.popen != nil { + o.popen.stdin = append(o.popen.stdin, p...) + return len(p) + } if o.isStr { if o.appendMode { // append mode: every write lands at end, ignoring position o.pos = len(o.buf) @@ -199,6 +250,32 @@ func (vm *VM) registerIO() { } { cIO.consts[name] = object.IntValue(val) } + // `require "fcntl"` installs the Fcntl constant module (ext/fcntl/fcntl.c), + // and `require "io/nonblock"` the IO#nonblock accessors + // (ext/io/nonblock/nonblock.c) — both lazily as MRI does, so neither resolves + // before its require. The featureHooks map is already created by registerPrime, + // which runs first. + vm.featureHooks["fcntl"] = vm.installFcntl + vm.featureHooks["io/nonblock"] = func() { installIONonblock(cIO) } + // IO::WaitReadable / IO::WaitWritable (io.c Init_IO) are the marker modules a + // non-blocking read or write raises with: IO::EAGAINWaitReadable is an + // Errno::EAGAIN subclass that includes IO::WaitReadable, so `rescue + // IO::WaitReadable` and `e.is_a?(Errno::EAGAIN)` both hold — MRI 4.0.5 on this + // host reports IO::EAGAINWaitReadable "Resource temporarily unavailable - read + // would block" and [true, true] for those two predicates. EWOULDBLOCK is EAGAIN + // on every platform rbgo targets, so IO::EWOULDBLOCKWait* is the very same + // class under a second name, as MRI makes it. + eagain := vm.consts["Errno::EAGAIN"].(*RClass) // registered with File, above + for _, w := range []string{"WaitReadable", "WaitWritable"} { + mod := newClass("IO::"+w, nil) + mod.isModule = true + cIO.consts[w], vm.consts["IO::"+w] = mod, mod + exc := newClass("IO::EAGAIN"+w, eagain) + exc.includes = append(exc.includes, mod) + for _, name := range []string{"EAGAIN" + w, "EWOULDBLOCK" + w} { + cIO.consts[name], vm.consts["IO::"+name] = exc, exc + } + } if fc, ok := vm.consts["File"].(*RClass).consts["Constants"].(*RClass); ok { cIO.includes = append(cIO.includes, fc) } @@ -228,6 +305,19 @@ func (vm *VM) registerIO() { cIO.define("fileno", fileno) cIO.methods["to_i"] = cIO.methods["fileno"] // #to_i is a true alias of #fileno + // IO#pid (rb_io_pid): the pid of the child a stream was opened onto — only + // IO.popen makes one — and nil for every other stream. A closed stream raises + // IOError, as GetOpenFile does. + cIO.define("pid", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + if o.closed { + raise("IOError", "closed stream") + } + if o.popen == nil { + return object.NilV + } + return object.IntValue(o.popen.pid) + }) // IO#to_io returns the IO itself (rb_io_to_io), for open or closed streams. cIO.define("to_io", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { return self @@ -645,7 +735,7 @@ func openFileIO(cls *RClass, p, mode string) *IOObj { if mode == "" { raise("ArgumentError", "invalid access mode %s", mode) } - o := &IOObj{cls: cls, isStr: true, path: p} + o := &IOObj{cls: cls, isStr: true, path: p, openMode: mode} switch mode[0] { case 'r': if notRegular(p) { @@ -681,7 +771,17 @@ func openFileIO(cls *RClass, p, mode string) *IOObj { o.writable = true // as above: nothing to append to that can be read break } - b, _ := os.ReadFile(p) // append to the existing content (or a new file) + b, err := os.ReadFile(p) // append to the existing content (or a new file) + if err != nil { + // O_APPEND carries O_CREAT in MRI's "a"/"a+" fmode (io.c + // rb_io_fmode_oflags: FMODE_APPEND ⇒ O_CREAT|O_APPEND), so the file + // exists on disk as soon as it is opened — `File.open(p, "a")` then + // `File.exist?(p)` is true before any write. Materialise it now, as + // the 'w' branch above does for O_CREAT|O_TRUNC. + if werr := os.WriteFile(p, nil, 0o644); werr != nil { + raise("Errno::ENOENT", "No such file or directory @ rb_sysopen - %s", p) + } + } o.buf, o.pos, o.writable = b, len(b), true default: raise("ArgumentError", "invalid access mode %s", mode) @@ -924,9 +1024,7 @@ func defIOWrite(cls *RClass) { cls.define("close", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { o := self.(*IOObj) ioFlush(o) - if o.pipe != nil && o.isWriteEnd { - o.pipe.wClosed = true // signal EOF to the read end - } + o.pipeEndClosed() o.closed = true return object.NilV }) @@ -961,10 +1059,13 @@ func defIOWrite(cls *RClass) { return e } } - // A write-only stream with no explicit encoding reports nil (unless a - // default internal encoding forces transcoding); a readable one reports - // Encoding.default_external. - if o.writable && object.IsNil(vm.send(vm.cEncoding, "default_internal", nil, nil)) { + // rb_io_external_encoding: with no converter and no encoding recorded, a + // WRITABLE stream reports nil, while a readable one falls back to + // Encoding.default_external (io_read_encoding). Once #set_encoding has + // resolved the pair (encSet) the empty name IS the answer; otherwise the + // open-time resolution is reproduced lazily, and a default internal + // encoding is what would have forced a converter into place. + if ioFmodeWritable(o) && (o.encSet || object.IsNil(vm.send(vm.cEncoding, "default_internal", nil, nil))) { return object.NilV } return vm.send(vm.cEncoding, "default_external", nil, nil) @@ -1021,10 +1122,50 @@ func defIOWrite(cls *RClass) { if o.intEnc == o.extEnc { o.intEnc = "" } + if object.IsNil(pos[0]) { + o.extEnc, o.intEnc = vm.extIntToEnc(o.intEnc) + } + o.encSet = true return self }) } +// extIntToEnc resolves a #set_encoding whose external argument was nil, which +// io.c io_encoding_set passes to rb_io_ext_int_to_enc with a NULL external: the +// external becomes Encoding.default_external (default_ext), and an internal that +// was not named becomes Encoding.default_internal. When there is no internal left +// — or it equals the external — no converter is needed, and the recorded encoding +// is NULL precisely when the external was defaulted and differs from the +// internal; otherwise the pair is kept and drives the converter. +// +// The consequence the specs pin down is that the answer is FROZEN here: a stream +// reset with `set_encoding nil, nil` while the defaults are UTF-8 / nil reports +// nil for both afterwards even once the defaults change, whereas one reset while +// they are IBM437 / IBM866 reports that pair. +func (vm *VM) extIntToEnc(named string) (ext, intn string) { + if vm.defExternalEnc != nil { + ext = vm.defExternalEnc.name + } + intn = named + if intn == "" && ext != "ASCII-8BIT" && vm.defInternalEnc != nil { + intn = vm.defInternalEnc.name + } + if intn == "" || intn == ext { + if intn != ext { // a defaulted external with no internal records nothing + return "", "" + } + return ext, "" + } + return ext, intn +} + +// ioFmodeWritable reports MRI's FMODE_WRITABLE for a stream: a file opened for +// writing, a pipe write end, or one of the writer-backed standard streams +// ($stdout / $stderr, whose bytes go straight to an io.Writer). It is the +// predicate rb_io_external_encoding branches on, and is deliberately not +// o.wrClosed: a pipe READ end leaves that false while being read-only. +func ioFmodeWritable(o *IOObj) bool { return o.writable || o.w != nil } + // ioReadEnc returns the (external, internal) encoding names in effect for a // whole-stream read, filling the unset sides from Encoding.default_external and // Encoding.default_internal. A BINARY external encoding — or an internal equal @@ -1725,7 +1866,7 @@ func enumSizeNil(*VM) object.Value { return object.NilV } // side effect per line. A successful (non-nil) read advances #lineno. func (vm *VM) ioGetsResolved(o *IOObj, sep getsSep, limit int, chomp bool) object.Value { o.pipeRefresh() - v := ioGetsLine(o, sep, limit, chomp) + v := vm.ioGetsLine(o, sep, limit, chomp) if v != object.NilV { o.lineno++ // A line is read in the external encoding (the separator is matched on those @@ -1824,7 +1965,7 @@ type getsSep struct { // ioGetsLine reads one line honouring the separator, an optional byte limit // (negative for none) and chomp, advancing the cursor. It returns nil at EOF. -func ioGetsLine(o *IOObj, sep getsSep, limit int, chomp bool) object.Value { +func (vm *VM) ioGetsLine(o *IOObj, sep getsSep, limit int, chomp bool) object.Value { if o.pos >= len(o.buf) { return object.NilV } @@ -1839,7 +1980,8 @@ func ioGetsLine(o *IOObj, sep getsSep, limit int, chomp bool) object.Value { } } if limit >= 0 && o.pos+limit < end { - end = o.pos + limit + ext, _ := vm.ioReadEnc(o) + end = vm.relaxGetsLimit(o.buf, o.pos, o.pos+limit, end, ext) } line := o.buf[o.pos:end] o.pos = end @@ -1849,6 +1991,51 @@ func ioGetsLine(o *IOObj, sep getsSep, limit int, chomp bool) object.Value { return object.NewString(string(line)) } +// relaxGetsLimit returns the end offset a byte-limited gets should actually stop +// at. io.c rb_io_getline_0 does not cut a line in the middle of a character: when +// the limit is exhausted and the trailing character is still incomplete +// (MBCLEN_NEEDMORE_P on the last character), it relaxes the limit by one byte and +// reads again, up to an extra_limit of 16 bytes. The trailing character is +// re-anchored on every pass (rb_enc_prev_char), which is what makes a run of +// truncated leads consume the full 16 rather than stopping at the first invalid +// sequence. hardEnd caps the relaxation at the separator (or end of stream), as +// appendline's newline check does. +func (vm *VM) relaxGetsLimit(buf []byte, start, end, hardEnd int, enc string) int { + switch enc { + case "", "ASCII-8BIT", "US-ASCII", "ISO-8859-1": + return end // a single-byte encoding has no character to split + } + anchor := start + for extra := 16; extra > 0 && end < hardEnd && anchor < end; extra-- { + anchor += lastCharStart(vm, buf[anchor:end], enc) + if _, _, _, st := vm.decodeCharFrom(buf[anchor:end], enc); st != stepIncomplete { + return end + } + end++ + } + return end +} + +// lastCharStart returns the offset within s at which its final character begins, +// walking the characters forward from the start of the slice (MRI's +// rb_enc_prev_char scans backwards from the end, but only ever to find the same +// boundary). An invalid sequence is stepped over by its maximal valid subpart, so +// a run of truncated leads re-anchors on the last of them. +func lastCharStart(vm *VM, s []byte, enc string) int { + last := 0 + for i := 0; i < len(s); { + last = i + _, n, _, st := vm.decodeCharFrom(s[i:], enc) + if st == stepIncomplete { + return last + } + // Every codec consumes at least one byte once it has decided (an invalid + // lead reports a one-byte subpart), so the floor is a guard, not a branch. + i += max(n, 1) + } + return last +} + // getsChomp removes a single trailing separator run from line (the "\n" default // also strips a preceding "\r", matching MRI's universal-newline chomp). func getsChomp(line []byte, sep string) []byte { diff --git a/internal/vm/io_class.go b/internal/vm/io_class.go index 8333f73a..b4b3ef1c 100644 --- a/internal/vm/io_class.go +++ b/internal/vm/io_class.go @@ -140,7 +140,7 @@ func (vm *VM) registerIOClassMethods(cIO, cFile *RClass) { // block when iterated. io.c: rb_io_s_foreach returns enum_for(:foreach). return enumForSized(self, "foreach", enumSizeNil, args...) } - o := openFileIO(cFile, pathArg(vm, pos[0]), "r") + o := vm.ioOpenForeach(pos[0], opts) sep, limit, chomp := vm.resolveGetsArgs(pos[1:]) chomp = optChomp(opts, chomp) checkResolvedLimit(limit, "foreach") @@ -159,7 +159,7 @@ func (vm *VM) registerIOClassMethods(cIO, cFile *RClass) { if len(pos) == 0 { raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") } - o := openFileIO(cFile, pathArg(vm, pos[0]), "r") + o := vm.ioOpenForeach(pos[0], opts) sep, limit, chomp := vm.resolveGetsArgs(pos[1:]) chomp = optChomp(opts, chomp) checkResolvedLimit(limit, "readlines") @@ -171,6 +171,40 @@ func (vm *VM) registerIOClassMethods(cIO, cFile *RClass) { }) } +// ioOpenForeach opens the file IO.foreach / IO.readlines will read — io.c +// open_key_args, which does NOT force O_RDONLY when options are given: an +// :open_args Array supplies the mode (superseding the sibling options), otherwise +// the :mode option does, and only a call with no options at all defaults to +// O_RDONLY. Opening with a write mode therefore creates the file and the read +// that follows raises IOError, where forcing "r" raised Errno::ENOENT instead. +// rb_io_getline_0 opens with rb_io_check_char_readable, so the check is made here +// — nothing happens between the open and the first line. +func (vm *VM) ioOpenForeach(path object.Value, opts *object.Hash) *IOObj { + cFile := vm.consts["File"].(*RClass) + o := openFileIO(cFile, pathArg(vm, path), modeBase(ioForeachMode(opts))) + ioCheckReadable(o) + return o +} + +// ioForeachMode returns the access mode IO.foreach / IO.readlines open with: the +// mode inside :open_args if that option is present, else the :mode option, else +// "r" (io.c open_key_args ⇒ rb_io_open ⇒ rb_io_extract_modeenc). +func ioForeachMode(opts *object.Hash) string { + if opts == nil { + return "r" + } + if oa, ok := opts.Get(object.Symbol("open_args")); ok { + if m := ioOpenArgsMode(oa); m != "" { + return m + } + return "r" + } + if m := ioOptMode(opts); m != "" { + return m + } + return "r" +} + // ioAdoptDescriptor makes o wrap the descriptor src under the mode/encoding // decoded from (pos, opts): the shared buffer/path, the read/write access half // (an explicit mode incompatible with the descriptor is EINVAL, no mode inherits diff --git a/internal/vm/io_descriptors.go b/internal/vm/io_descriptors.go index b66ddd09..e2a2f21c 100644 --- a/internal/vm/io_descriptors.go +++ b/internal/vm/io_descriptors.go @@ -7,6 +7,7 @@ package vm import ( "math/big" "os" + "strings" "github.com/go-embedded-ruby/ruby/internal/object" ) @@ -171,11 +172,28 @@ func defIOReadExtra(cls *RClass) { o.lineno = vm.ioCIntArg(args[0]) return args[0] }) + // #close_read / #close_write shut one half of a DUPLEX stream — which, on a + // real IO, only IO.popen in a "+" mode makes. io.c rb_io_close_read raises + // "closing non-duplex IO for reading" when a non-duplex stream is WRITABLE + // (so File.open(p, "w") and File.open(p, "w+") both raise) and otherwise + // closes the whole stream; rb_io_close_write is the mirror image. Both answer + // nil on an already-closed stream before any of that (fptr->fd < 0). cls.define("close_read", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { o := self.(*IOObj) if o.rdModeOff { // a StringIO opened write-only has no read half to close (MRI) raise("IOError", "not opened for reading") } + if !ioIsStringIO(o) && !o.duplex { + if o.closed { + return object.NilV + } + if !o.wrClosed { + raise("IOError", "closing non-duplex IO for reading") + } + o.rdClosed, o.closed = true, true + o.pipeEndClosed() + return object.NilV + } o.rdClosed = true if o.wrClosed { // both halves shut ⇒ the stream is fully closed o.closed = true @@ -187,6 +205,18 @@ func defIOReadExtra(cls *RClass) { if o.wrModeOff { // a StringIO opened read-only has no write half to close (MRI) raise("IOError", "not opened for writing") } + if !ioIsStringIO(o) && !o.duplex { + if o.closed { + return object.NilV + } + if !o.rdClosed { + raise("IOError", "closing non-duplex IO for writing") + } + ioFlush(o) + o.wrClosed, o.closed = true, true + o.pipeEndClosed() + return object.NilV + } ioFlush(o) o.wrClosed = true if o.rdClosed { @@ -235,9 +265,10 @@ func defIOSeekable(cls *RClass) { if !raiseOnBlock { return object.Symbol("wait_writable") } - raise("Errno::EAGAIN", "Resource temporarily unavailable - write would block") + raise("IO::EAGAINWaitWritable", "Resource temporarily unavailable - write would block") } } + o.nonblock = true // io_write_nonblock leaves the descriptor non-blocking return object.IntValue(vm.ioWriteAll(o, pos[:1])) }) cls.define("pread", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { @@ -437,3 +468,298 @@ func ioReadResult(data []byte, buf *object.String) object.Value { } return object.NewStringBytes(append([]byte(nil), data...)) } + +// defIOReopen installs IO#reopen and IO#fcntl — the two descriptor-rebinding +// methods MRI implements on top of dup2()/freopen()/fcntl(), modelled here on the +// buffered IOObj the VM uses in place of real file descriptors. +func defIOReopen(cls *RClass) { + // IO#reopen(other_io) / #reopen(path, mode = nil, **opts) -> self + // + // io.c rb_io_reopen: with exactly one positional argument that converts to an + // IO (rb_io_check_io, i.e. #to_io), the receiver adopts that stream + // (io_reopen); otherwise the argument is a path (FilePathValue, i.e. #to_path) + // and the receiver is re-bound to a freshly opened stream on it. Either way the + // receiver object itself is mutated — #object_id is unchanged — and self is + // returned. + cls.define("reopen", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + pos, opts := splitIOOpts(args) + if len(pos) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + // rb_scan_args(argc, argv, "11:") reports one argument only when no second + // positional mode was given; the IO form is tried only then. + if len(pos) == 1 { + if other, ok := ioConvertIO(vm, pos[0], false); ok { + return ioReopenIO(vm, o, other) + } + } + return ioReopenPath(vm, o, pos, opts) + }) + + // IO#fcntl(cmd, arg = 0) — io.c rb_io_fcntl. rbgo has no real descriptors, so + // the answer is derived from the stream's own recorded state rather than from + // the host: F_GETFL reports the access mode and O_APPEND the stream was opened + // with, and F_GETFD reports FD_CLOEXEC from the close-on-exec flag. The two + // setters are accepted and ignored (there is no descriptor to change), and any + // other command is refused the way MRI refuses an unsupported one. + cls.define("fcntl", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + if o.closed { + raise("IOError", "closed stream") + } + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + switch intArg(args[0]) { + case fcntlFGetFD: + if o.closeOnExecOff { + return object.IntValue(0) + } + return object.IntValue(fdCloexec) + case fcntlFGetFL: + return object.IntValue(ioOflags(o)) + case fcntlFSetFD, fcntlFSetFL: + return object.IntValue(0) + } + raise("Errno::EINVAL", "Invalid argument - fcntl(2)") + return object.NilV + }) +} + +// fcntl(2) command numbers and the FD_CLOEXEC descriptor flag. As with the +// File::Constants open flags (fO_RDONLY and friends), the canonical POSIX values +// are fixed here so behaviour does not drift with the host's . +const ( + fcntlFGetFD = 1 + fcntlFSetFD = 2 + fcntlFGetFL = 3 + fcntlFSetFL = 4 + fdCloexec = 1 +) + +// ioStdioStream reports whether o is one of the three streams MRI keeps a FILE* +// for ($stdin / $stdout / $stderr), which #reopen freopen()s rather than +// rb_sysopen()ing — the two take different open flags. +func ioStdioStream(o *IOObj) bool { + switch o.label { + case "STDIN", "STDOUT", "STDERR": + return true + } + return false +} + +// ioOflags rebuilds the open-flag set (the answer fcntl(F_GETFL) gives) from a +// stream's recorded access mode: io.c rb_io_fmode_oflags maps FMODE_READWRITE to +// O_RDWR, a write-only mode to O_WRONLY and FMODE_APPEND to O_APPEND. O_CREAT and +// O_TRUNC are deliberately absent — they act at open(2) time and a real +// fcntl(F_GETFL) never reports them. +func ioOflags(o *IOObj) int64 { + var fl int64 + switch { + case !o.rdClosed && !o.wrClosed: + fl = fO_RDWR + case o.rdClosed: + fl = fO_WRONLY + default: + fl = fO_RDONLY + } + if o.appendMode { + fl |= fO_APPEND + } + return fl +} + +// ioConvertIO converts v to an IO through #to_io. It is the one body behind +// io.c's pair of conversions, which differ only in what a missing #to_io means: +// rb_io_check_io (strict false, rb_check_convert_type_with_id) reports "not an +// IO" so the caller can treat v as something else — IO#reopen treats it as a +// path — while rb_io_get_io (strict true, rb_convert_type_with_id) raises. Both +// raise when #to_io answers something that is not an IO, nil included. +func ioConvertIO(vm *VM, v object.Value, strict bool) (*IOObj, bool) { + if o, ok := v.(*IOObj); ok { + return o, true + } + if !vm.respondsToDynamic(v, "to_io") { + if strict { + raise("TypeError", "no implicit conversion of %s into IO", vm.classOf(v).name) + } + return nil, false + } + r := vm.send(v, "to_io", nil, nil) + if o, ok := r.(*IOObj); ok { + return o, true + } + if object.IsNil(r) && !strict { + return nil, false + } + raise("TypeError", "can't convert %s to IO (%s#to_io gives %s)", + vm.classOf(v).name, vm.classOf(v).name, vm.classOf(r).name) + return nil, false +} + +// ioGetIO is the strict conversion (rb_io_get_io): IO.select takes every element +// of its argument arrays through it, so a non-IO raises rather than being +// silently skipped. +func ioGetIO(vm *VM, v object.Value) *IOObj { + o, _ := ioConvertIO(vm, v, true) + return o +} + +// ioReopenIO re-binds o onto other's stream — io.c io_reopen, which dup2()s +// other's descriptor over o's and copies the mode, encodings, pid, lineno and +// path across, then sets o's class to other's. Both streams must be open. +// +// A standard stream keeps its identity instead of adopting the target wholesale: +// MRI dup2()s onto descriptor 0/1/2 and leaves the FILE* in place, so STDOUT stays +// STDOUT and only its destination moves. That is also the shape the rest of this +// VM relies on — Kernel#fork snapshots and restores exactly this redirection +// around a forked block (spawn.go runForkBlock), which a wholesale copy could not +// express. +func ioReopenIO(vm *VM, o, other *IOObj) object.Value { + if o.closed { + raise("IOError", "closed stream") + } + if other.closed { + raise("IOError", "closed stream") + } + if o == other { + return o + } + // Buffered writes reach the old destination before the descriptor moves + // (io.c calls io_fflush on both streams first). + ioFlush(o) + ioFlush(other) + // close-on-exec is always set afresh on the reopened stream, whatever either + // side carried (rb_io_reopen ends with rb_fd_fix_cloexec on the new fd). + o.closeOnExecOff = false + if ioStdioStream(o) { + o.reopened = other + return o + } + o.buf, o.pos, o.lineno = other.buf, other.pos, other.lineno + o.rdClosed, o.wrClosed, o.writable = other.rdClosed, other.wrClosed, other.writable + o.appendMode, o.openMode, o.binmode = other.appendMode, other.openMode, other.binmode + o.extEnc, o.intEnc = other.extEnc, other.intEnc + o.isStr, o.w, o.pipe, o.isWriteEnd = other.isStr, other.w, other.pipe, other.isWriteEnd + o.pipeSynced, o.reopened = other.pipeSynced, other.reopened + // "if (RTEST(orig->pathv)) fptr->pathv = orig->pathv; else … fptr->pathv = Qnil" + o.path = other.path + o.cls = other.cls // RBASIC_SET_CLASS(io, rb_obj_class(nfile)) + return o +} + +// ioReopenPath re-binds o onto a freshly opened stream on the given path — the +// second half of io.c rb_io_reopen. With no mode argument the stream keeps the +// access mode it was opened with ("oflags = rb_io_fmode_oflags(fptr->mode)"), so +// a writable stream re-creates (and truncates) the new file while a read-only one +// raises Errno::ENOENT for a path that does not exist. A closed stream reopens. +// A stream that was never opened from a path has no mode to carry over, and the +// two branches below say what MRI does for each kind of those. +func ioReopenPath(vm *VM, o *IOObj, pos []object.Value, opts *object.Hash) object.Value { + name := pathArg(vm, pos[0]) + mode, keepHalves := o.openMode, false + explicit := (len(pos) > 1 && !object.IsNil(pos[1])) || opts != nil + switch { + case explicit: + if len(pos) > 1 && !object.IsNil(pos[1]) { + mode = vm.vmodeString(pos[1]) + } else if m, ok := opts.Get(object.Symbol("mode")); ok && !object.IsNil(m) { + mode = vm.vmodeString(m) + } + case mode != "": + // The mode the stream was opened with carries over unchanged. + case ioStdioStream(o): + // A standard stream has a FILE* behind it, which rb_io_reopen freopen()s + // with the modestr its fmode maps to (rb_io_oflags_modestr) — so a write + // mode CREATES the file. $stdin is read-only, $stdout and $stderr + // write-only. MRI 4.0.5 on this host: $stdout.reopen(a missing path) + // succeeds and writes it. + mode = "r" + if o.rdClosed { + mode = "w" + } + default: + // Every other stream with no path behind it — a pipe end, a bare + // descriptor wrapper — is rb_sysopen()ed with the RAW oflags of + // rb_io_fmode_oflags, which carry the access half but neither O_CREAT nor + // O_TRUNC. So the file must already exist and its content survives, and + // the stream keeps its own halves. MRI 4.0.5 on this host: + // IO.pipe[1].reopen(a missing path) raises Errno::ENOENT, and onto a file + // holding "0123456789" a following write of "AB" leaves "AB23456789". + mode, keepHalves = "r+", true + } + // The old destination receives whatever is still buffered before the stream + // moves (io.c io_fflush), and the read buffer is dropped (rbuf.off = len = 0). + if !o.closed { + ioFlush(o) + } + fresh := openFileIO(o.cls, name, modeBase(mode)) + o.buf, o.pos, o.path = fresh.buf, fresh.pos, fresh.path + if keepHalves { + o.writable, o.appendMode, o.openMode = !o.wrClosed, false, "" + } else { + o.rdClosed, o.wrClosed, o.writable = fresh.rdClosed, fresh.wrClosed, fresh.writable + o.appendMode, o.openMode = fresh.appendMode, fresh.openMode + } + o.isStr, o.closed, o.lineno = true, false, 0 + o.w, o.pipe, o.isWriteEnd, o.pipeSynced, o.reopened = nil, nil, false, 0, nil + o.closeOnExecOff = false + if explicit { + ms := vm.ioResolveModeEnc(pos, opts) + o.extEnc, o.intEnc, o.binmode = ms.extEnc, ms.intEnc, ms.binmode + } + return o +} + +// The fcntl and io/nonblock standard-library extensions are supplied by the VM +// rather than by a Ruby file, so `require "fcntl"` / `require "io/nonblock"` must +// succeed without finding one. Registering them here (rather than in require.go's +// table) keeps each feature beside the IO surface that implements it. +func init() { + providedFeatures["fcntl"] = true + providedFeatures["io/nonblock"] = true +} + +// installFcntl creates the Fcntl module — ext/fcntl/fcntl.c, which defines +// nothing but constants. Only the commands IO#fcntl above actually answers are +// defined (with FD_CLOEXEC, the flag F_GETFD reports), together with the open +// flags, whose values mirror File::Constants so `io.fcntl(Fcntl::F_GETFL) & +// File::APPEND` compares like with like. +func (vm *VM) installFcntl() { + mod := newClass("Fcntl", nil) + mod.isModule = true + vm.consts["Fcntl"] = mod + for name, val := range map[string]int64{ + "F_GETFD": fcntlFGetFD, "F_SETFD": fcntlFSetFD, + "F_GETFL": fcntlFGetFL, "F_SETFL": fcntlFSetFL, + "FD_CLOEXEC": fdCloexec, + } { + mod.consts[name] = object.IntValue(val) + } + for name, val := range fileFlagConsts { + mod.consts["O_"+name] = object.IntValue(val) + } + for name, val := range fileExtraConsts { + if strings.HasPrefix(name, "LOCK_") { + continue // flock(2) operations are File::Constants only, not Fcntl + } + mod.consts["O_"+name] = object.IntValue(val) + } +} + +// installIONonblock adds IO#nonblock? / #nonblock= — ext/io/nonblock/nonblock.c, +// which reads and writes O_NONBLOCK on the descriptor with fcntl(2). rbgo has no +// descriptors, so the flag lives on the stream: a file starts blocking and a pipe +// end starts non-blocking, which is what MRI 4.0.5 reports on this host +// ([false, true, true] for a File and the two ends of IO.pipe). The accessors are +// installed on the first `require "io/nonblock"`, as MRI installs them. +func installIONonblock(cls *RClass) { + cls.define("nonblock?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self.(*IOObj).nonblock) + }) + cls.define("nonblock=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*IOObj).nonblock = args[0].Truthy() + return args[0] + }) +} diff --git a/internal/vm/io_wave23_test.go b/internal/vm/io_wave23_test.go new file mode 100644 index 00000000..32f5488f --- /dev/null +++ b/internal/vm/io_wave23_test.go @@ -0,0 +1,744 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// w23dir lays out the small fixture tree the reopen/gets tests read: a two-line +// file, a one-line file, and a UTF-8 file whose characters straddle any byte +// limit. It returns the forward-slashed directory so paths can be embedded in +// Ruby source directly. +func w23dir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + write := func(name string, data []byte) { + if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil { + t.Fatal(err) + } + } + write("two.txt", []byte("Line 1\nLine 2\n")) + write("one.txt", []byte("XX\n")) + // "朝日" followed by 100 truncated three-byte leads — the fixture + // core/io/gets_spec.rb builds to exercise the extra_limit relaxation. + data := []byte("\xE6\x9C\x9D\xE6\x97\xA5") + for i := 0; i < 100; i++ { + data = append(data, 0xE3, 0x81) + } + write("kanji.txt", data) + return slash(dir) +} + +// TestIOReopenIOFormWave23 covers IO#reopen's other-IO form (io.c io_reopen): +// the class and path adopted from the target, reading from the target's current +// position, the #to_io conversion and its TypeError, the closed-stream IOErrors +// on either side, and reopening a stream onto itself. Every expected value was +// produced by MRI Ruby 4.0.5 on this host. +func TestIOReopenIOFormWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + // The receiver takes the target's class, path and content. + {`f=File.open("` + d + `/one.txt"); g=File.open("` + d + `/two.txt") +p [f.reopen(g).class.to_s, f.path == g.path, f.gets]`, + "[\"File\", true, \"Line 1\\n\"]\n"}, + // It starts at the target's current position, not at the beginning. + {`f=File.open("` + d + `/one.txt"); g=File.open("` + d + `/two.txt"); g.gets +p f.reopen(g).gets`, "\"Line 2\\n\"\n"}, + // Reopening a stream onto itself is a no-op returning self. + {`f=File.open("` + d + `/one.txt"); p f.reopen(f).equal?(f)`, "true\n"}, + // #to_io is called, and its IO adopted. + {`f=File.open("` + d + `/one.txt"); g=File.open("` + d + `/two.txt") +o=Object.new; o.define_singleton_method(:to_io) { g } +p f.reopen(o).gets`, "\"Line 1\\n\"\n"}, + // A #to_io that answers something other than an IO is a TypeError. + {`f=File.open("` + d + `/one.txt"); o=Object.new; def o.to_io; "s"; end +begin; f.reopen(o); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "TypeError: can't convert Object to IO (Object#to_io gives String)\n"}, + // A #to_io answering nil is "not an IO": the argument becomes a path, and + // an object that is no path either is the String TypeError. + {`f=File.open("` + d + `/one.txt"); o=Object.new; def o.to_io; nil; end +begin; f.reopen(o); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "TypeError: no implicit conversion of Object into String\n"}, + // Both a closed receiver and a closed target raise IOError. + {`f=File.open("` + d + `/one.txt"); g=File.open("` + d + `/two.txt"); g.close +begin; f.reopen(g); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closed stream\n"}, + {`f=File.open("` + d + `/one.txt"); f.close +begin; f.reopen(File.open("` + d + `/two.txt")); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closed stream\n"}, + // A writable receiver flushes to its own file before the descriptor moves. + {`f=File.open("` + d + `/w1.txt","w"); f.print "kept" +g=File.open("` + d + `/w2.txt","w"); f.reopen(g); f.print "moved"; f.flush +p [File.read("` + d + `/w1.txt"), File.read("` + d + `/w2.txt")]`, + "[\"kept\", \"moved\"]\n"}, + // close-on-exec is set afresh on the reopened stream. + {`f=File.open("` + d + `/one.txt"); f.close_on_exec=false +p f.reopen(File.open("` + d + `/two.txt")).close_on_exec?`, "true\n"}, + // No positional argument at all. + {`f=File.open("` + d + `/one.txt") +begin; f.reopen; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: wrong number of arguments (given 0, expected 1..2)\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOReopenStandardStreamWave23 covers the standard-stream branch of +// io_reopen: MRI dup2()s onto descriptor 1 and leaves the FILE* in place, so +// STDOUT keeps its identity and only its destination moves — the shape +// Kernel#fork's snapshot of the redirection depends on. +func TestIOReopenStandardStreamWave23(t *testing.T) { + src := `r, w = IO.pipe +$stdout.reopen(w) +print "redirected" +$stdout.reopen(STDERR) +p r.read_nonblock(20)` + if got := runFS(t, src); got != "\"redirected\"\n" { + t.Errorf("got %q", got) + } +} + +// TestIOReopenPathFormWave23 covers IO#reopen's path form (the freopen() half of +// rb_io_reopen): the mode carried over from the original open, an explicit mode +// argument, the same mode given as a :mode option, #to_path conversion, +// reopening a closed stream, and Errno::ENOENT for a read-only stream pointed at +// a file that does not exist. Values verified against MRI Ruby 4.0.5. +func TestIOReopenPathFormWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + // The write mode is kept, so the old file holds what was flushed to it and + // the new one is created and written. + {`f=File.open("` + d + `/p1.txt","w"); f.print "original data" +f.reopen("` + d + `/p2.txt"); f.print "new data"; f.flush +p [File.read("` + d + `/p1.txt"), File.read("` + d + `/p2.txt")]`, + "[\"original data\", \"new data\"]\n"}, + // An explicit mode replaces it; "ab" shows through fcntl(F_GETFL). + {`require "fcntl" +f=File.open("` + d + `/one.txt"); f.reopen("` + d + `/p3.txt","ab") +p (f.fcntl(Fcntl::F_GETFL) & File::APPEND) == File::APPEND`, "true\n"}, + // The mode may arrive as a :mode option instead. + {`f=File.open("` + d + `/one.txt"); p f.reopen("` + d + `/two.txt", mode: "r").gets`, + "\"Line 1\\n\"\n"}, + // A non-String argument goes through #to_path. + {`f=File.open("` + d + `/one.txt") +o=Object.new; o.define_singleton_method(:to_path) { "` + d + `/two.txt" } +p f.reopen(o).gets`, "\"Line 1\\n\"\n"}, + // A closed stream reopens, and reads from the beginning. + {`f=File.open("` + d + `/one.txt"); f.close; f.reopen("` + d + `/two.txt","r") +p [f.closed?, f.gets]`, "[false, \"Line 1\\n\"]\n"}, + // A read-only stream pointed at a missing file raises, as the open does. + {`f=File.open("` + d + `/one.txt","r") +begin; f.reopen("` + d + `/absent.txt"); rescue => e; puts e.class; end`, + "Errno::ENOENT\n"}, + // The encoding options of an explicit mode are adopted. + {`f=File.open("` + d + `/one.txt") +f.reopen("` + d + `/two.txt", "r:ISO-8859-1") +p f.external_encoding.name`, "\"ISO-8859-1\"\n"}, + // close-on-exec is set afresh here too. + {`f=File.open("` + d + `/one.txt"); f.close_on_exec=false +p f.reopen("` + d + `/two.txt").close_on_exec?`, "true\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOFcntlWave23 covers IO#fcntl and the Fcntl constant module: F_GETFL for +// each access mode, F_GETFD reading the close-on-exec flag, the two setters, an +// unsupported command and a closed stream. MRI Ruby 4.0.5 on this host answers +// 0 / 9 / 2 to the three F_GETFL calls below and 1 / 0 to the two F_GETFD calls. +// +// A mode carrying O_CREAT is deliberately not asserted: a real fcntl(F_GETFL) on +// a freshly created file returns host status bits on top of the access mode +// (65537 for "w" here) that a synthetic answer cannot reproduce. The access mode +// and O_APPEND — all the open-mode flags a caller can act on, and all +// core/io/reopen_spec.rb compares — are exact. +func TestIOFcntlWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + {`require "fcntl" +p [File.open("` + d + `/two.txt","r").fcntl(Fcntl::F_GETFL), + File.open("` + d + `/f1.txt","ab").fcntl(Fcntl::F_GETFL), + File.open("` + d + `/f1.txt","r+").fcntl(Fcntl::F_GETFL)]`, + "[0, 9, 2]\n"}, + // The write-only access bit still shows through, whatever a host would add. + {`require "fcntl" +p File.open("` + d + `/f2.txt","w").fcntl(Fcntl::F_GETFL) & 3`, "1\n"}, + {`require "fcntl" +f=File.open("` + d + `/two.txt") +a=f.fcntl(Fcntl::F_GETFD); f.close_on_exec=false +p [a, f.fcntl(Fcntl::F_GETFD), Fcntl::FD_CLOEXEC]`, "[1, 0, 1]\n"}, + {`require "fcntl" +f=File.open("` + d + `/two.txt") +p [f.fcntl(Fcntl::F_SETFD, 0), f.fcntl(Fcntl::F_SETFL, 0)]`, "[0, 0]\n"}, + // An unsupported command is refused. MRI's errno here is whatever the host + // kernel picks for the command (EPERM on this macOS), which rbgo cannot + // reproduce without a descriptor; it answers the canonical EINVAL instead. + {`require "fcntl" +begin; File.open("` + d + `/two.txt").fcntl(99); rescue => e; puts e.class; end`, + "Errno::EINVAL\n"}, + {`require "fcntl" +f=File.open("` + d + `/two.txt"); f.close +begin; f.fcntl(Fcntl::F_GETFL); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closed stream\n"}, + {`require "fcntl" +begin; File.open("` + d + `/two.txt").fcntl; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: wrong number of arguments (given 0, expected 1..2)\n"}, + // The open-flag mirrors are present, and the flock operations are not. + {`require "fcntl" +p [Fcntl::O_APPEND == File::APPEND, Fcntl::O_NONBLOCK == File::NONBLOCK, + Fcntl.const_defined?(:O_LOCK_EX)]`, "[true, true, false]\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestFileOpenAppendCreatesWave23 covers the O_CREAT that FMODE_APPEND carries +// (io.c rb_io_fmode_oflags): File.open(path, "a") materialises the file before +// any write, which MRI Ruby 4.0.5 reports as File.exist? => true. +func TestFileOpenAppendCreatesWave23(t *testing.T) { + d := w23dir(t) + src := `p [File.exist?("` + d + `/ap.txt"), + (File.open("` + d + `/ap.txt","a"){}; File.exist?("` + d + `/ap.txt"))]` + if got := runFS(t, src); got != "[false, true]\n" { + t.Errorf("got %q", got) + } + // A path whose parent does not exist cannot be created, and reports the + // same Errno::ENOENT the read side of the open would. + if got := runFSErr(t, `File.open("`+d+`/nodir/ap.txt","a")`); got != "Errno::ENOENT" { + t.Errorf("append open in a missing directory: got %q", got) + } +} + +// TestGetsLimitCharBoundaryWave23 covers relaxGetsLimit / lastCharStart: a limit +// that lands inside a character is relaxed until the character is complete +// (io.c rb_io_getline_0), bounded by extra_limit = 16 and by the separator; a +// single-byte encoding is never relaxed; and a zero limit still returns "". +// Values verified against MRI Ruby 4.0.5. +func TestGetsLimitCharBoundaryWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + // One byte of a three-byte character reads the whole character. + {`f=File.open("` + d + `/kanji.txt","r:utf-8"); p [f.gets(1), f.gets(1)]`, + "[\"朝\", \"日\"]\n"}, + // A run of truncated leads consumes exactly the 16 extra bytes allowed. + {`f=File.open("` + d + `/kanji.txt","r:utf-8"); p f.gets(7).bytesize`, "23\n"}, + // A limit falling on a boundary is left alone. + {`f=File.open("` + d + `/kanji.txt","r:utf-8"); p f.gets(6).bytesize`, "6\n"}, + // A zero limit returns the empty string without relaxing anything. + {`f=File.open("` + d + `/kanji.txt","r:utf-8"); p f.gets(0)`, "\"\"\n"}, + // A single-byte encoding has no character to split. + {`f=File.open("` + d + `/kanji.txt","rb"); p f.gets(1).bytes`, "[230]\n"}, + // The relaxation stops at the separator rather than crossing it. + {`f=File.open("` + d + `/two.txt"); p [f.gets(10), f.gets(10)]`, + "[\"Line 1\\n\", \"Line 2\\n\"]\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestForeachOpenOptionsWave23 covers ioOpenForeach / ioForeachMode: IO.foreach +// and IO.readlines honour :mode and :open_args rather than forcing "r" +// (io.c open_key_args), so a write mode creates the file and the read that +// follows raises IOError — MRI Ruby 4.0.5 raises IOError "not opened for +// reading" and leaves the file in place. +func TestForeachOpenOptionsWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + // No options at all: the plain read. + {`p IO.readlines("` + d + `/two.txt")`, "[\"Line 1\\n\", \"Line 2\\n\"]\n"}, + // :mode "w" opens for writing, so the file appears and the read raises. + {`begin; IO.readlines("` + d + `/m1.txt", mode: "w"); rescue => e; puts "#{e.class}: #{e.message}"; end +p File.exist?("` + d + `/m1.txt")`, + "IOError: not opened for reading\ntrue\n"}, + {`begin; IO.foreach("` + d + `/m2.txt", 10, mode: "w") {}; rescue => e; puts e.class; end`, + "IOError\n"}, + // :open_args supplies the mode instead. + {`begin; IO.readlines("` + d + `/m3.txt", open_args: ["w"]); rescue => e; puts e.class; end`, + "IOError\n"}, + // :open_args carrying the mode inside its options Hash. + {`begin; IO.readlines("` + d + `/m4.txt", open_args: [{mode: "w"}]); rescue => e; puts e.class; end`, + "IOError\n"}, + // :open_args with no mode at all falls back to reading. + {`p IO.readlines("` + d + `/two.txt", open_args: [{}])`, + "[\"Line 1\\n\", \"Line 2\\n\"]\n"}, + // A :mode option that is not a String is ignored, as ioOptMode reports none. + {`p IO.readlines("` + d + `/two.txt", mode: nil)`, + "[\"Line 1\\n\", \"Line 2\\n\"]\n"}, + // The limit relaxation reaches IO.readlines too. + {`p IO.readlines("` + d + `/kanji.txt", 1)[0, 2]`, "[\"朝\", \"日\"]\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOSelectChecksWave23 covers IO.select's argument handling (io.c +// rb_f_select / select_internal): the timeout is converted first, each set must +// be an Array, and each element goes through rb_io_get_io. Every message below +// is the one MRI Ruby 4.0.5 prints on this host. +func TestIOSelectChecksWave23(t *testing.T) { + cases := []struct{ src, want string }{ + {`begin; IO.select; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: wrong number of arguments (given 0, expected 1..4)\n"}, + {`begin; IO.select([Object.new]); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "TypeError: no implicit conversion of Object into IO\n"}, + {`o=Object.new; def o.to_io; nil; end +begin; IO.select([o]); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "TypeError: can't convert Object to IO (Object#to_io gives NilClass)\n"}, + {`begin; IO.select(Object.new); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "TypeError: wrong argument type Object (expected Array)\n"}, + {`begin; IO.select(nil, Object.new); rescue => e; puts e.class; end`, "TypeError\n"}, + {`begin; IO.select(nil, nil, Object.new); rescue => e; puts e.class; end`, "TypeError\n"}, + {`begin; IO.select(nil, nil, nil, Object.new); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "TypeError: can't convert Object into time interval\n"}, + {`begin; IO.select(nil, nil, nil, -5); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: time interval must not be negative\n"}, + {`begin; IO.select(nil, nil, nil, Float::NAN); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "RangeError: NaN out of Time range\n"}, + // The supplied object comes back, not its #to_io conversion. + {`r,w=IO.pipe; w.write("x") +o=Object.new; o.define_singleton_method(:to_io) { r } +p IO.select([o]) == [[o], [], []]`, "true\n"}, + // An exception set is type-checked but never reported ready. + {`r,w=IO.pipe; w.write("x"); p IO.select([r], nil, [r]) == [[r], [], []]`, "true\n"}, + // A pipe write end is not read-ready. + {`r,w=IO.pipe; p IO.select([w], nil, nil, 0)`, "nil\n"}, + // Nothing ready with a zero timeout is nil. + {`r,w=IO.pipe; p IO.select([r], nil, nil, 0)`, "nil\n"}, + // A nil timeout is accepted. MRI would block here forever waiting for the + // writer; this VM has no concurrent writer to wait for, so it answers at + // once — the one place where IO.select cannot mirror MRI. + {`r,w=IO.pipe; p IO.select([r], nil, nil, nil)`, "nil\n"}, + // A pipe reader whose write end has closed is ready (the read sees EOF). + {`r,w=IO.pipe; w.close; p IO.select([r]) == [[r], [], []]`, "true\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOSelectRegularFileWave23 covers ioSelectReadable's non-pipe branch: a +// regular file is always reported readable and writable, the way select(2) +// reports one. MRI Ruby 4.0.5 answers [[io], [io], []] for the same program. +func TestIOSelectRegularFileWave23(t *testing.T) { + d := w23dir(t) + src := `io = File.open("` + d + `/sel.txt", "w+") +r, w, e = IO.select([io], [io], nil, 0) +p [r == [io], w == [io], e]` + if got := runFS(t, src); got != "[true, true, []]\n" { + t.Errorf("got %q", got) + } +} + +// TestIONonblockWave23 covers `require "io/nonblock"` and the accessors it +// installs (ext/io/nonblock/nonblock.c). MRI Ruby 4.0.5 on this host reports +// [false, true, true] for a File and the two ends of IO.pipe. +func TestIONonblockWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + {`p require("io/nonblock")`, "true\n"}, + {`require "io/nonblock" +r,w=IO.pipe; f=File.open("` + d + `/two.txt") +p [f.nonblock?, r.nonblock?, w.nonblock?]`, "[false, true, true]\n"}, + {`require "io/nonblock" +f=File.open("` + d + `/two.txt") +f.nonblock = true; a = f.nonblock? +f.nonblock = false +p [a, f.nonblock?]`, "[true, false]\n"}, + // A non-blocking read or write leaves the descriptor non-blocking. + {`require "io/nonblock" +r,w=IO.pipe; w.write("abc"); r.nonblock = false; r.read_nonblock(1) +p r.nonblock?`, "true\n"}, + {`require "io/nonblock" +r,w=IO.pipe; w.nonblock = false; w.write_nonblock("a") +p w.nonblock?`, "true\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOReadNonblockWave23 covers IO#read_nonblock in the order io.c +// io_read_nonblock fixes: the negative length first, then the output buffer, +// then the readability check, then the zero-length shortcut, and finally the +// would-block and end-of-file answers in both their raising and their +// exception: false forms. Values verified against MRI Ruby 4.0.5. +func TestIOReadNonblockWave23(t *testing.T) { + cases := []struct{ src, want string }{ + {`r,w=IO.pipe +begin; r.read_nonblock(-1); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: negative length -1 given\n"}, + {`r,w=IO.pipe +begin; r.read_nonblock; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: wrong number of arguments (given 0, expected 1..2)\n"}, + {`r,w=IO.pipe; r.close +begin; r.read_nonblock(5); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closed stream\n"}, + {`r,w=IO.pipe; p r.read_nonblock(0)`, "\"\"\n"}, + {`r,w=IO.pipe +begin; r.read_nonblock(5); rescue => e + puts "#{e.class}: #{e.message}"; p [e.is_a?(IO::WaitReadable), e.is_a?(Errno::EAGAIN)] +end`, + "IO::EAGAINWaitReadable: Resource temporarily unavailable - read would block\n[true, true]\n"}, + {`r,w=IO.pipe; p r.read_nonblock(5, exception: false)`, ":wait_readable\n"}, + {`r,w=IO.pipe; w.write("hello"); w.close +r.read_nonblock(5) +p r.read_nonblock(5, exception: false)`, "nil\n"}, + {`r,w=IO.pipe; w.write("hello"); w.close +r.read_nonblock(5) +begin; r.read_nonblock(5); rescue => e; puts e.class; end`, "EOFError\n"}, + // The output buffer is filled and returned, and emptied on the EOF path. + {`r,w=IO.pipe; w.write("hello world"); w.close +buf = +"existing content" +out = r.read_nonblock(11, buf) +p [buf, out.equal?(buf)]`, "[\"hello world\", true]\n"}, + {`r,w=IO.pipe; w.close +buf = +"existing content" +begin; r.read_nonblock(1, buf); rescue => e; puts e.class; end +p buf`, "EOFError\n\"\"\n"}, + // A nil buffer argument is simply no buffer. + {`r,w=IO.pipe; w.write("ab"); p r.read_nonblock(5, nil)`, "\"ab\"\n"}, + // IO::EWOULDBLOCKWaitReadable is the same class under a second name. + {`p IO::EWOULDBLOCKWaitReadable.equal?(IO::EAGAINWaitReadable)`, "true\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOWriteNonblockWaitWritableWave23 covers the would-block answer of +// IO#write_nonblock: once the modelled pipe capacity is full the write raises +// IO::EAGAINWaitWritable (or answers :wait_writable), which is what MRI Ruby +// 4.0.5 raises when a real pipe is full. +func TestIOWriteNonblockWaitWritableWave23(t *testing.T) { + cases := []struct{ src, want string }{ + {`r,w=IO.pipe +begin + loop { w.write_nonblock("a"*10_000) } +rescue => e + puts e.class; p [e.is_a?(IO::WaitWritable), e.is_a?(Errno::EAGAIN)] +end`, "IO::EAGAINWaitWritable\n[true, true]\n"}, + {`r,w=IO.pipe +loop { break if w.write_nonblock("a"*10_000, exception: false) == :wait_writable } +p w.write_nonblock("a"*10_000, exception: false)`, ":wait_writable\n"}, + {`p IO::EWOULDBLOCKWaitWritable.equal?(IO::EAGAINWaitWritable)`, "true\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestSetEncodingNilResolvesDefaultsWave23 covers extIntToEnc / ioFmodeWritable: +// `set_encoding nil, nil` resolves through io.c rb_io_ext_int_to_enc against the +// defaults in force AT THAT MOMENT and freezes the answer, so a later change to +// Encoding.default_external cannot move it. Which answer it freezes depends on +// whether a default internal encoding was in force: with one, the pair is +// recorded and both accessors report it; without one, a defaulted external +// records nothing and a writable stream reports nil for both. Every expected +// line below was produced by MRI Ruby 4.0.5 on this host. +func TestSetEncodingNilResolvesDefaultsWave23(t *testing.T) { + d := w23dir(t) + reset := `Encoding.default_external = Encoding::UTF_8; Encoding.default_internal = nil +` + bump := `Encoding.default_external = Encoding::IBM437; Encoding.default_internal = Encoding::IBM866 +` + cases := []struct{ src, want string }{ + // Defaults changed BEFORE the reset: the new pair is captured. + {reset + `io = File.open("` + d + `/e1.txt", "w:utf-8:us-ascii") +` + bump + `io.set_encoding nil, nil +p [io.external_encoding, io.internal_encoding]`, + "[#, #]\n"}, + // Defaults changed AFTER the reset: the stream keeps the nil it recorded. + {reset + `io = File.open("` + d + `/e2.txt", "w:utf-8:us-ascii") +io.set_encoding nil, nil +` + bump + `p [io.external_encoding, io.internal_encoding]`, + "[nil, nil]\n"}, + // Same for a stream that never named an encoding. + {reset + `io = File.open("` + d + `/e3.txt", "w") +io.set_encoding nil, nil +` + bump + `p [io.external_encoding, io.internal_encoding]`, + "[nil, nil]\n"}, + // A READABLE stream reports the external encoding rather than nil. + {reset + `io = File.open("` + d + `/two.txt", "r") +` + bump + `io.set_encoding nil, nil +p [io.external_encoding, io.internal_encoding]`, + "[#, #]\n"}, + // $stdout is writable even though it is not a file, so it resets to nil. + {reset + `STDOUT.set_encoding(Encoding::US_ASCII, Encoding::ISO_8859_1) +STDOUT.set_encoding(nil, nil) +p [STDOUT.external_encoding, STDOUT.internal_encoding]`, + "[nil, nil]\n"}, + // An internal encoding equal to the external needs no converter, so only + // the external is recorded. + {`Encoding.default_external = Encoding::IBM437; Encoding.default_internal = Encoding::IBM437 +io = File.open("` + d + `/e4.txt", "w") +io.set_encoding nil, nil +p [io.external_encoding, io.internal_encoding]`, + "[#, nil]\n"}, + // A named internal encoding with a nil external takes the default external. + {reset + `io = File.open("` + d + `/e5.txt", "w") +io.set_encoding nil, Encoding::IBM866 +p [io.external_encoding.name, io.internal_encoding.name]`, + "[\"UTF-8\", \"IBM866\"]\n"}, + // A BINARY default external suppresses the internal side entirely. + {`Encoding.default_external = Encoding::BINARY; Encoding.default_internal = Encoding::IBM866 +io = File.open("` + d + `/e6.txt", "w") +io.set_encoding nil, nil +p [io.external_encoding, io.internal_encoding]`, + "[nil, nil]\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOPopenWave23 covers IO.popen (io.c rb_io_s_popen / pipe_open): the stream +// it returns, the access halves the mode gives it, the block form that closes +// afterwards, $? and #pid, and the argument splitting. Every expected value was +// produced by MRI Ruby 4.0.5 on this host, EXCEPT what a child would read on its +// standard input: the child has already finished by the time the parent holds +// the stream (this VM's process model is synchronous), so a write cannot reach +// it. That limit is asserted here as rbgo's behaviour, not as MRI's. +func TestIOPopenWave23(t *testing.T) { + if runtimeIsWasm() { + t.Skip("no subprocesses under wasm") + } + cases := []struct { + src, want string + // childText marks a case whose expected value carries text the CHILD + // process wrote. See the Windows note above the loop. + childText bool + }{ + {`io = IO.popen("echo foo", "r"); p [io.closed?, io.read]`, + "[false, \"foo\\n\"]\n", true}, + // A read-only stream cannot be written, and the read still works after. + {`io = IO.popen("echo foo", "r") +begin; io.write("bar"); rescue => e; puts e.class; end +p io.read`, "IOError\n\"foo\\n\"\n", true}, + // A write-only stream cannot be read. + {`io = IO.popen("cat", "w") +begin; io.read; rescue => e; puts e.class; end +p io.write("bar")`, "IOError\n3\n", false}, + // A "+" mode is duplex: each half closes on its own, and the stream stays + // open until both are shut. + {`io = IO.popen("cat", "r+") +p [io.closed?, io.close_read, io.closed?, io.close_write, io.closed?]`, + "[false, nil, false, nil, true]\n", false}, + // The block form yields the stream and closes it afterwards. + {`v = IO.popen("echo blk", "r") { |io| io.read } +p v`, "\"blk\\n\"\n", true}, + // $? carries the child's status, and #pid reports it. + {`io = IO.popen("echo hi", "r"); p [$?.class.to_s, io.pid.class.to_s, $?.exitstatus]`, + "[\"Process::Status\", \"Integer\", 0]\n", false}, + // A leading environment Hash and a trailing options Hash are both peeled off + // before the command is read. + {`p IO.popen({"FOO" => "bar"}, "echo one").read`, "\"one\\n\"\n", true}, + {`p IO.popen("echo two", "r", err: [:child, :out]).read`, "\"two\\n\"\n", true}, + // "-" asks for a forked interpreter, which needs a working fork. + {`begin; IO.popen("-"); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "NotImplementedError: fork() function is unimplemented on this machine\n", false}, + {`begin; IO.popen; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "ArgumentError: wrong number of arguments (given 0, expected 1..2)", false}, + // An explicitly nil mode is the "r" default. + {`p IO.popen("echo nil", nil).read`, "\"nil\\n\"\n", true}, + // A subclass receiver produces an instance of that subclass (popen_finish + // does RBASIC_SET_CLASS(port, klass)). + {`class MyIO < IO; end +p MyIO.popen("echo sub", "r").class.to_s`, "\"MyIO\"\n", false}, + } + // On Windows the shell's own `echo` terminates its line with CRLF, and rbgo + // hands those bytes to the reader untranslated: it has no text-mode newline + // conversion on the read path, which is what would turn "foo\r\n" back into + // "foo\n" the way MRI does there. That gap is real but unverified — there is + // no Windows MRI on this host to compare against — so the cases whose expected + // value carries the child's own line ending are left to the POSIX lanes rather + // than asserted one way or the other here. + for _, c := range cases { + if c.childText && runtime.GOOS == "windows" { + continue + } + got := runFS(t, c.src) + if c.want[len(c.want)-1] != '\n' { // an arity message, compared by prefix + if len(got) < len(c.want) || got[:len(c.want)] != c.want { + t.Errorf("src=%q got=%q want prefix %q", c.src, got, c.want) + } + continue + } + if got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } + // The documented limit: bytes written to a duplex popen stream are counted but + // cannot reach a child that has already run, so `cat` echoes nothing back. + // MRI answers "12345\n" here; this asserts rbgo's synchronous model instead. + if got := runFS(t, `io = IO.popen("cat", "r+"); io.puts "12345"; io.close_write; p io.read`); got != "\"\"\n" { + t.Errorf("popen duplex write: got %q", got) + } +} + +// TestIOCloseHalfNonDuplexWave23 covers the non-duplex rule of #close_read / +// #close_write (io.c rb_io_close_read / rb_io_close_write): on a stream that is +// not duplexed, closing the read half of a WRITABLE stream — or the write half +// of a READABLE one — is an IOError, and closing the other half closes the whole +// stream. The pipe ends carry one half of the access mode each. Values verified +// against MRI Ruby 4.0.5. +func TestIOCloseHalfNonDuplexWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + {`io = File.open("` + d + `/c1.txt","w") +begin; io.close_read; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closing non-duplex IO for reading\n"}, + {`io = File.open("` + d + `/c2.txt","w+") +begin; io.close_write; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closing non-duplex IO for writing\n"}, + {`io = File.open("` + d + `/c3.txt","w"); io.close_write; p io.closed?`, "true\n"}, + {`io = File.open("` + d + `/two.txt","r"); io.close_read; p io.closed?`, "true\n"}, + // An already-closed stream answers nil to both, before any of that. + {`io = IO.popen("cat","r+"); io.close; p [io.close_read, io.close_write]`, + "[nil, nil]\n"}, + // Each pipe end carries one half of the mode. + {`r, w = IO.pipe +begin; r.write("x"); rescue => e; puts e.class; end +begin; w.read; rescue => e; puts e.class; end +r.close_read; w.close_write +p [r.closed?, w.closed?]`, "IOError\nIOError\n[true, true]\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// runtimeIsWasm reports whether the test binary runs on a target with no +// subprocesses, where IO.popen raises instead of running anything. +func runtimeIsWasm() bool { return runtime.GOARCH == "wasm" } + +// TestPipeBrokenPipeWave23 covers pipeEndClosed and the Errno::EPIPE a write to +// a pipe whose read end has gone reports. core/io/shared/write.rb asserts it for +// #write, #syswrite and #write_nonblock alike; MRI 4.0.5 on this host raises +// Errno::EPIPE "Broken pipe" for all three, and does not die from SIGPIPE. +func TestPipeBrokenPipeWave23(t *testing.T) { + cases := []struct{ src, want string }{ + {`r,w=IO.pipe; r.close +begin; w.write("x"); rescue => e; puts "#{e.class}: #{e.message}"; end`, + "Errno::EPIPE: Broken pipe\n"}, + {`r,w=IO.pipe; r.close +begin; w.syswrite("x"); rescue => e; puts e.class; end`, "Errno::EPIPE\n"}, + {`r,w=IO.pipe; r.close +begin; w.write_nonblock("x"); rescue => e; puts e.class; end`, "Errno::EPIPE\n"}, + // #close_read on the read end breaks the pipe just as #close does. + {`r,w=IO.pipe; r.close_read +begin; w.write("x"); rescue => e; puts e.class; end`, "Errno::EPIPE\n"}, + // Closing the WRITE end is end-of-file for the reader, not a broken pipe. + {`r,w=IO.pipe; w.write("ok"); w.close; p r.read`, "\"ok\"\n"}, + {`r,w=IO.pipe; w.write("ok"); w.close_write; p r.read`, "\"ok\"\n"}, + // A block-form pipe closes both ends behind it. + {`r = nil; w = nil +IO.pipe { |a, b| r, w = a, b } +p [r.closed?, w.closed?]`, "[true, true]\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestIOReopenNoRecordedModeWave23 covers ioReopenPath's two branches for a +// stream that was never opened from a path, which io.c rb_io_reopen treats +// differently: a standard stream has a FILE* and is freopen()ed with the modestr +// its fmode maps to (so $stdout.reopen CREATES the file and $stdin.reopen reads +// it), while a pipe end or bare descriptor wrapper is rb_sysopen()ed with the raw +// oflags — the access half only, no O_CREAT and no O_TRUNC. Every value below was +// produced by MRI Ruby 4.0.5 on this host. +func TestIOReopenNoRecordedModeWave23(t *testing.T) { + d := w23dir(t) + // A pipe write end keeps its writable half and does NOT truncate: writing two + // bytes over "0123456789" leaves "AB23456789". + if got := runFS(t, `File.write("`+d+`/ex.txt", "0123456789") +r, w = IO.pipe +w.reopen("`+d+`/ex.txt"); w.print "AB"; w.flush +p File.read("`+d+`/ex.txt")`); got != "\"AB23456789\"\n" { + t.Errorf("pipe writer reopen: got %q", got) + } + // A pipe read end keeps its readable half. + if got := runFS(t, `r, w = IO.pipe; r.reopen("`+d+`/two.txt"); p r.gets`); got != "\"Line 1\\n\"\n" { + t.Errorf("pipe reader reopen: got %q", got) + } + // Neither carries O_CREAT, so a path that does not exist is Errno::ENOENT. + if got := runFSErr(t, `r, w = IO.pipe; w.reopen("`+d+`/absent.txt")`); got != "Errno::ENOENT" { + t.Errorf("pipe writer reopen onto a missing path: got %q", got) + } + // $stdin is read-only, and reopening it reads the file. + if got := runFS(t, `$stdin.reopen("`+d+`/two.txt"); p $stdin.gets`); got != "\"Line 1\\n\"\n" { + t.Errorf("$stdin reopen: got %q", got) + } + // $stdout is write-only, and reopening it onto a path that does not exist + // still succeeds — freopen's "w" creates. Its output no longer reaches the + // captured stream, so the file is read back through a second run's value. + if got := runFS(t, `$stdout.reopen("`+d+`/so.txt") +print "via reopen" +$stdout.flush +$stdout.reopen(STDERR) +File.write("`+d+`/so_echo.txt", File.read("`+d+`/so.txt"))`); got != "" { + t.Errorf("$stdout reopen: captured stdout should be empty, got %q", got) + } + if b, err := os.ReadFile(filepath.Join(filepath.FromSlash(d), "so_echo.txt")); err != nil || string(b) != "via reopen" { + t.Errorf("$stdout reopen wrote %q (err %v), want %q", b, err, "via reopen") + } +} + +// TestIOClosedHalfClosesAndPidWave23 covers the remaining guards of the +// descriptor surface: #close_read / #close_write answer nil on an already-closed +// NON-duplex stream (io.c returns at fptr->fd < 0, before the non-duplex +// refusal), and IO#pid is nil for a stream with no child behind it and IOError +// once it is closed. MRI Ruby 4.0.5 on this host prints exactly these lines. +func TestIOClosedHalfClosesAndPidWave23(t *testing.T) { + d := w23dir(t) + cases := []struct{ src, want string }{ + {`f = File.open("` + d + `/two.txt"); f.close; p [f.close_read, f.close_write]`, + "[nil, nil]\n"}, + {`f = File.open("` + d + `/cw.txt","w"); f.close; p [f.close_read, f.close_write]`, + "[nil, nil]\n"}, + {`p File.open("` + d + `/two.txt").pid`, "nil\n"}, + {`f = File.open("` + d + `/two.txt"); f.close +begin; f.pid; rescue => e; puts "#{e.class}: #{e.message}"; end`, + "IOError: closed stream\n"}, + } + for _, c := range cases { + if got := runFS(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} diff --git a/internal/vm/spawn.go b/internal/vm/spawn.go index e53164b4..4c05ec86 100644 --- a/internal/vm/spawn.go +++ b/internal/vm/spawn.go @@ -5,6 +5,7 @@ package vm import ( + "math" "strings" "github.com/go-embedded-ruby/ruby/internal/object" @@ -75,39 +76,78 @@ func (vm *VM) registerSpawn() { intn = "" } buf := &pipeBuf{} - reader := &IOObj{cls: cls, pipe: buf, label: "pipe-r", extEnc: ext, intEnc: intn} - writer := &IOObj{cls: cls, pipe: buf, isWriteEnd: true, writable: true, label: "pipe-w"} + // Both ends of a pipe come back with O_NONBLOCK already set, which is what + // MRI 4.0.5 reports through io/nonblock on this host. Each end carries one + // half of the access mode (rb_io_s_pipe opens FMODE_READABLE on the read end + // and FMODE_WRITABLE on the write end), so reading the write end or writing + // the read end raises, and #close_read/#close_write close the right one. + reader := &IOObj{cls: cls, pipe: buf, label: "pipe-r", extEnc: ext, intEnc: intn, nonblock: true, wrClosed: true} + writer := &IOObj{cls: cls, pipe: buf, isWriteEnd: true, writable: true, label: "pipe-w", nonblock: true, rdClosed: true} pair := object.NewArray(reader, writer) if blk != nil { // IO.pipe { |r, w| ... } yields the pair and closes both ends after. - defer func() { reader.closed, writer.closed, buf.wClosed = true, true, true }() + defer func() { + reader.closed, writer.closed = true, true + buf.wClosed, buf.rClosed = true, true + }() return vm.callBlock(blk, []object.Value{reader, writer}) } return pair }} - // read_nonblock / readpartial drain available pipe bytes; at EOF (write end - // closed, nothing buffered) they raise EOFError, as MRI does. - nonblock := func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + // read_nonblock(len, outbuf = nil, exception: true) — io.c io_read_nonblock. + // The order matters and is observable: a negative length is ArgumentError + // before anything else, then the output buffer is coerced (io_setstrbuf), then + // the stream is checked readable (GetOpenFile / rb_io_check_byte_readable), and + // only then does a zero length return the emptied buffer. A read that would + // block raises IO::EAGAINWaitReadable (or returns :wait_readable), and the + // descriptor is left in non-blocking mode (rb_fd_set_nonblock); at end of file + // the output buffer is emptied and EOFError raised (or nil returned). + cIO.define("read_nonblock", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + pos, opts := splitIOOpts(args) + if len(pos) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + n := vm.ioOfftArg(pos[0]) + if n < 0 { + raise("ArgumentError", "negative length %d given", n) + } + var buf *object.String + if len(pos) > 1 && !object.IsNil(pos[1]) { + buf = vm.ioBufferArg(pos[1]) + } + raiseOnBlock := true + if opts != nil { + if v, ok := opts.Get(object.Symbol("exception")); ok { + raiseOnBlock = v.Truthy() + } + } + ioCheckReadable(o) + if n == 0 { + return ioReadResult(nil, buf) + } o.pipeRefresh() - if o.pos >= len(o.buf) { - if o.pipeWriterClosed() { + o.nonblock = true // rb_fd_set_nonblock on the descriptor being read + if avail := len(o.buf) - o.pos; avail <= 0 { + if o.pipe == nil || o.pipeWriterClosed() { // end of file + ioReadResult(nil, buf) // io_set_read_length(str, 0) empties the buffer + if !raiseOnBlock { + return object.NilV + } raise("EOFError", "end of file reached") } - raise("Errno::EAGAIN", "Resource temporarily unavailable - read would block") - } - n := len(o.buf) - o.pos - if len(args) > 0 { - if m := int(intArg(args[0])); m < n { - n = m + if !raiseOnBlock { + return object.Symbol("wait_readable") } + raise("IO::EAGAINWaitReadable", "Resource temporarily unavailable - read would block") + } else if n > avail { + n = avail } - s := object.NewString(string(o.buf[o.pos : o.pos+n])) + data := o.buf[o.pos : o.pos+n] o.pos += n - return s - } - cIO.define("read_nonblock", nonblock) + return ioReadResult(data, buf) + }) // readpartial(maxlen, outbuf = nil): a length-limited read that, unlike // read_nonblock, blocks for data rather than raising EAGAIN. It returns at most @@ -155,39 +195,74 @@ func (vm *VM) registerSpawn() { return ioReadResult(data, buf) }) - // reopen rebinds a standard stream onto another IO (Puppet's safe_posix_fork - // does STDOUT.reopen(pipe_writer)); subsequent writes forward to the target. - cIO.define("reopen", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { - o := self.(*IOObj) - if target, ok := args[0].(*IOObj); ok { - o.reopened = target + // IO#reopen and IO#fcntl live in io_descriptors.go, beside the rest of the + // descriptor-level surface. Its standard-stream branch is what Puppet's + // safe_posix_fork STDOUT.reopen(pipe_writer) rides on, and what runForkBlock + // below snapshots and restores around a forked block. + defIOReopen(cIO) + + // IO.popen([env,] cmd, mode = "r", **opts) — io.c rb_io_s_popen, which peels a + // trailing options Hash and then a leading environment Hash before reading the + // command and the mode, and pipe_open, which gives back a stream whose + // readable/writable halves follow that mode (so "r" raises IOError on write and + // "w" raises IOError on read). With a block the stream is yielded and closed + // afterwards (popen_finish → pipe_close), and $? carries the child's status. + // + // The child runs TO COMPLETION here and its output is buffered, which is the + // process model this whole file is built on (see the header): there is no + // concurrent child, so nothing the parent writes afterwards can reach its + // standard input — the child is run with an empty one. Everything else about + // the stream is real: the write half accepts bytes and reports their count, + // close_write/close_read shut the halves, and the mode gates both. + cIO.smethods["popen"] = &Method{name: "popen", owner: cIO, native: func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + cls := cIO + if c, ok := self.(*RClass); ok { + cls = c } - return self - }) + o := vm.popenOpen(cls, args) + if blk == nil { + return o + } + defer func() { o.closed, o.rdClosed, o.wrClosed = true, true, true }() + return vm.callBlock(blk, []object.Value{o}) + }} - // IO.select reports readiness. A reader is ready when it has buffered bytes or - // its write end is closed (so a subsequent read returns EOF rather than - // blocking). Writers and exception sets are always reported ready, matching the - // non-blocking, fully-synchronous model. - cIO.smethods["select"] = &Method{name: "select", owner: cIO, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + // IO.select(read, write, except, timeout) reports readiness. io.c rb_f_select + // converts the timeout first (rb_time_interval), then select_internal type- + // checks each of the three sets with Check_Type(T_ARRAY) and puts every element + // through rb_io_get_io (#to_io) — so a non-Array set, a non-IO element and a + // bad timeout all raise before any readiness is examined. The SUPPLIED object + // is what comes back in the result, not its #to_io conversion. + // + // Readiness itself follows this VM's synchronous model: a regular (buffer- + // backed) stream is always ready, as select(2) reports a regular file; a pipe + // reader is ready when it has buffered bytes or its write end is closed (so a + // subsequent read returns EOF rather than blocking) and a pipe writer is never + // read-ready; writers and exception sets are always reported ready. + cIO.smethods["select"] = &Method{name: "select", owner: cIO, native: 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..4)") + } + if len(args) > 3 && !object.IsNil(args[3]) { + if f := mutexSleepDur(args[3]); math.IsNaN(f) { + raise("RangeError", "NaN out of Time range") + } + } readReady := object.NewArray() - if len(args) > 0 { - if rs, ok := args[0].(*object.Array); ok { - for _, v := range rs.Elems { - if o, ok := v.(*IOObj); ok { - o.pipeRefresh() - if o.pos < len(o.buf) || o.pipeWriterClosed() { - readReady.Elems = append(readReady.Elems, o) - } - } - } + for _, v := range selectSet(vm, args, 0) { + o := ioGetIO(vm, v) + o.pipeRefresh() + if ioSelectReadable(o) { + readReady.Elems = append(readReady.Elems, v) } } writeReady := object.NewArray() - if len(args) > 1 { - if ws, ok := args[1].(*object.Array); ok { - writeReady.Elems = append(writeReady.Elems, ws.Elems...) - } + for _, v := range selectSet(vm, args, 1) { + ioGetIO(vm, v) + writeReady.Elems = append(writeReady.Elems, v) + } + for _, v := range selectSet(vm, args, 2) { // type-checked, never reported ready + ioGetIO(vm, v) } if len(readReady.Elems) == 0 && len(writeReady.Elems) == 0 { return object.NilV @@ -199,6 +274,32 @@ func (vm *VM) registerSpawn() { vm.registerKernelExec() } +// selectSet returns the elements of IO.select's i-th argument set. A missing or +// nil set is empty; anything that is not an Array is the TypeError +// select_internal's Check_Type(T_ARRAY) raises. +func selectSet(vm *VM, args []object.Value, i int) []object.Value { + if i >= len(args) || object.IsNil(args[i]) { + return nil + } + arr, ok := args[i].(*object.Array) + if !ok { + raise("TypeError", "wrong argument type %s (expected Array)", vm.classOf(args[i]).name) + } + return arr.Elems +} + +// ioSelectReadable reports whether a read on o would return without blocking. A +// pipe end answers from the shared buffer (its write end never reads); anything +// else is a regular, fully-buffered stream, which select(2) always reports +// readable — including at end of file, where the read returns nil rather than +// blocking. +func ioSelectReadable(o *IOObj) bool { + if o.pipe != nil { + return !o.isWriteEnd && (o.pos < len(o.buf) || o.pipe.wClosed) + } + return true +} + // registerProcessSpawn adds spawn / waitpid2 / setsid / Status to the Process // module (already created by registerProcess). func (vm *VM) registerProcessSpawn() { @@ -441,3 +542,55 @@ func shellish(cmd []string) (string, bool) { } return s, false } + +// popenOpen builds the stream IO.popen hands back. It splits the arguments the +// way io.c rb_io_s_popen does — a trailing options Hash, then a leading +// environment Hash, then the command and an optional mode — runs the command, +// and returns a buffered stream holding its output whose access halves follow +// the mode (pipe_open passes fmode straight through). $? is set to the child's +// status, and #pid reports it. +func (vm *VM) popenOpen(cls *RClass, args []object.Value) *IOObj { + pos := args + if len(pos) > 1 { + if _, ok := pos[len(pos)-1].(*object.Hash); ok { + pos = pos[:len(pos)-1] // exec options; none of them are honoured here + } + } + if len(pos) > 1 { + if _, ok := pos[0].(*object.Hash); ok { + pos = pos[1:] // leading environment Hash — runCaptured takes no environment + } + } + if len(pos) < 1 || len(pos) > 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 1..2)", len(pos)) + } + mode := "r" + if len(pos) > 1 && !object.IsNil(pos[1]) { + mode = modeBase(vm.vmodeString(pos[1])) + } + // "-" asks pipe_open to fork the interpreter itself (is_popen_fork), which + // needs a working fork; MRI raises exactly this without one. + if s, ok := pos[0].(*object.String); ok && s.Str() == "-" { + raise("NotImplementedError", "fork() function is unimplemented on this machine") + } + out, code := runCaptured(spawnCommand(pos[:1])) + pid := vm.recordChild(code) + vm.globals["$?"] = vm.newProcessStatus(pid, code) + o := &IOObj{cls: cls, isStr: true, buf: []byte(out), label: "popen", nonblock: true, + popen: &popenProc{pid: pid}, openMode: mode} + // pipe_open hands back a stream whose halves are exactly the mode's: a bare + // "r" cannot be written and a bare "w" cannot be read, while any "+" mode is + // duplex (which is what IO#close_read / #close_write call a duplexed stream). + plus := strings.Contains(mode, "+") + o.rdClosed = !plus && mode != "r" + o.wrClosed = !plus && mode == "r" + o.duplex = plus // FMODE_DUPLEX: only a "+" popen has two independent halves + return o +} + +// popenProc records the child IO.popen ran, so #pid can report it and writes to +// the stream have somewhere to go that is not the buffer being read. +type popenProc struct { + pid int64 + stdin []byte +} diff --git a/internal/vm/spawn_internal_test.go b/internal/vm/spawn_internal_test.go index e51e8d76..6e397e65 100644 --- a/internal/vm/spawn_internal_test.go +++ b/internal/vm/spawn_internal_test.go @@ -131,7 +131,10 @@ func TestSpawnForkNoBlock(t *testing.T) { } // TestSpawnReadNonblock covers read_nonblock / readpartial: draining buffered -// bytes, EAGAIN when nothing is buffered, and EOFError once the writer closes. +// bytes, the would-block error when nothing is buffered, and EOFError once the +// writer closes. The would-block class is IO::EAGAINWaitReadable rather than a +// bare Errno::EAGAIN — MRI 4.0.5 on this host prints exactly the line asserted +// below (the previous pin predated IO::WaitReadable existing here). func TestSpawnReadNonblock(t *testing.T) { fake := func([]string) (string, int) { return "", 0 } out := runSpawn(t, `r, w = IO.pipe @@ -141,7 +144,7 @@ begin; r.read_nonblock(1); rescue => e; got << e.class.name; end w.close begin; r.read_nonblock(1); rescue => e; got << e.class.name; end p got`, fake) - if out != "[\"ab\", \"cd\", \"Errno::EAGAIN\", \"EOFError\"]\n" { + if out != "[\"ab\", \"cd\", \"IO::EAGAINWaitReadable\", \"EOFError\"]\n" { t.Fatalf("got %q", out) } }