pty: pwritev/pwritev2 with an offset return EINVAL; Linux returns ESPIPE
- Dominant language
- Go
- Stars
- 19.3k
- Forks
- 2k
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 264
Description
## Description
`pwritev(2)` / `pwritev2(2)` with an explicit offset against a **pty** (tty character device) return `EINVAL` under gVisor. Linux returns `ESPIPE` for positional I/O on non-seekable fds, and gVisor itself already returns `ESPIPE` for the same calls on **pipes** — only ptys diverge. `lseek` on the same pty correctly returns `ESPIPE`.
| call (real pty slave, `S_ISCHR` confirmed) | gVisor | Linux (runc, 6.x kernel) |
|---|---|---|
| `pwritev(fd, iov, 1, offset=0)` | **`EINVAL`** | `ESPIPE` |
| `pwritev2(fd, iov, 1, offset=0, flags=0)` | **`EINVAL`** | `ESPIPE` |
| same calls on a **pipe** | `ESPIPE` | `ESPIPE` |
| `lseek(fd, 0, SEEK_CUR)` on the pty | `ESPIPE` | `ESPIPE` |
| `pwritev2(fd, iov, 1, offset=-1, flags=0)` | ok (streams) | ok (streams) |
## Steps to reproduce
```python
import os, pty, ctypes, errno
m, s = pty.openpty()
libc = ctypes.CDLL(None, use_errno=True)
class IOV(ctypes.Structure): _fields_ = [("base", ctypes.c_void_p), ("len", ctypes.c_size_t)]
buf = ctypes.create_string_buffer(b"x"); iov = IOV(ctypes.cast(buf, ctypes.c_void_p), 1)
r = libc.syscall(296, s, ctypes.byref(iov), 1, 0, 0) # SYS_pwritev, offset=0
print("ret=%d errno=%s" % (r, errno.errorcode.get(ctypes.get_errno())))
# Linux/runc: ret=-1 errno=ESPIPE gVisor: ret=-1 errno=EINVAL
```
## Real-world impact
Zig 0.15's `std.fs.File.Writer` defaults to positional mode and attempts `pwritev(fd, iov, pos)` first, falling back to streaming `writev` **only** on `ESPIPE` (mapped to `error.Unseekable`; `EINVAL` maps to an unexpected error with no fallback). Under gVisor this silently breaks any Zig-0.15-built program that writes to a tty through that writer.
Concrete case: OpenTUI-based terminal UIs (e.g. the opencode CLI) render zero output under gVisor — the alternate screen is never entered and every frame is dropped silently, while the same binary works under runc. Diagnosed in:
- https://github.com/anomalyco/opentui/pull/1124 (application-side workaround: bypass the pwritev path)
- https://github.com/anomalyco/opencode/issues/29802 (full diagnosis trail)
A conformance fix here resolves the entire class rather than one application at a time.
## Environment
- Observed on GKE Sandbox (current managed runsc; `runsc -version` not accessible on the managed platform). Guest-reported kernel: `4.4.0`, dmesg: `Starting gVisor...`.
- Independently reported with the same `EINVAL` on AKS gVisor in the opentui PR above.
- Also reproduced with plain `os.pwritev(fd, [...], 0)` from Python (glibc wrapper) — same divergence.
Contributor guide
Research direction
Start at the pwritev and pwritev2 syscall entry points and compare their pty handling with the existing pipe behavior; lseek on the pty provides another ESPIPE reference. Reproduce the calls from the Python example, then verify that explicit offsets on ptys return ESPIPE while offset -1 retains streaming behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, linux
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100