google / google/gvisor

pty: pwritev/pwritev2 with an offset return EINVAL; Linux returns ESPIPE

Open
#13,737 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.