devpts: read() on PTY master returns EIO when no replica has ever been opened (Linux blocks)
- Dominant language
- Go
- Stars
- 19.3k
- Forks
- 2k
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 264
Description
## Description
A `read()` on a PTY **master** (`/dev/ptmx`) returns `EIO` when there is currently no open replica — **including before any replica has ever been opened**. On Linux, a master read in that state **blocks** until a replica opens; `EIO` is only returned *after* a replica has been opened and then closed (the `TTY_OTHER_CLOSED` hangup).
This divergence breaks any program that opens+unlocks a master, forks a child that will open the replica, and reads the master from the parent before the child's open lands — a very common PTY pattern. It manifests as an intermittent, scheduling-dependent failure.
## Minimal reproduction (no extra software)
In a gVisor sandbox:
```python
import os, fcntl, struct
m = os.open("/dev/ptmx", os.O_RDWR | os.O_NOCTTY)
fcntl.ioctl(m, 0x40045431, struct.pack("i", 0)) # TIOCSPTLCK: unlock
os.read(m, 1) # gVisor: raises OSError EIO immediately
# Linux (runc, same image/kernel): blocks (no replica yet)
```
- **gVisor:** `OSError: [Errno 5] Input/output error` immediately.
- **runc control (same image, host kernel 6.12.x):** blocks (killed by a timeout), i.e. correct Linux semantics.
## Real-world impact (Nix)
Nix uses a PTY as the builder's log channel: the parent `posix_openpt`+`unlockpt`s the master, `fork()`s the builder (which opens the replica **in the child**), then `readLine()`s the master. Under gVisor the parent's first read races the child's replica-open; when the read wins, it gets `EIO`, which Nix treats as EOF, and the build fails with `error: unexpected EOF reading a line` — even though the builder child completes successfully. It's intermittent (a two-instruction scheduling window) and worse under CPU load; on `runc` it never happens. See NixOS/nix#15181 for the downstream reports.
strace of a failing Nix build (parent), for illustration:
```
openat(AT_FDCWD, "/dev/ptmx", O_RDWR|O_NOCTTY) = 17
ioctl(17, TIOCGPTN, [29]) = 0
ioctl(17, TIOCSPTLCK, [0]) = 0
clone(...) = 989
read(17, ..., 1) = -1 EIO (Input/output error) # first read, child hasn't opened /dev/pts/29 yet
--- SIGCHLD {si_code=CLD_EXITED, si_pid=989, si_status=0} --- # builder exited 0
```
## Suspected cause
`pkg/sentry/fsimpl/devpts/queue.go` (`queue.read`) returns `EIO` whenever `numReplicas == 0`:
```go
if !q.readable {
if l.numReplicas == 0 {
return 0, false, false, linuxerr.EIO
}
return 0, false, false, linuxerr.ErrWouldBlock
}
```
`numReplicas` starts at 0 and increments on replica open; there is no "a replica has ever been opened" state, so the never-opened case is conflated with the opened-then-closed (hangup) case. Linux (`drivers/tty/pty.c`) returns master-read `EIO` only when `TTY_OTHER_CLOSED` is set, which is established in `pty_close()` — a never-opened replica makes the master read block. `masterReadiness()` appears to assert `EventHUp` whenever `numReplicas == 0` as well, so poll-based readers likely see the same divergence.
## Expected behavior
A `read()` on a PTY master should **block** (or return `EWOULDBLOCK` if non-blocking) while no replica has *ever* been opened, matching Linux; `EIO` should only be returned after a replica has been opened and subsequently closed.
## Environment
- Observed on GKE Sandbox: `runsc version google-930760319`, platform `xemu` (Google-built runsc). Happy to re-test against a specific upstream `runsc` release if that helps confirm.
- Guest: `debian:bookworm-slim`, x86_64.
- Reproduces with the ~5-line Python snippet above (no Nix required).
Contributor guide
Research direction
Start in pkg/sentry/fsimpl/devpts/queue.go, focusing on queue.read and masterReadiness, then compare their behavior with the Linux PTY semantics described in the issue. Reproduce the Python snippet and verify that a master read blocks before any replica opens, while EIO remains reserved for a replica that was opened and then closed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, linux
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100