google / google/gvisor

devpts: read() on PTY master returns EIO when no replica has ever been opened (Linux blocks)

Open
#13,738 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

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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.