> This page is for version Main (default).
> For other versions, use one of these documentation indexes:
> - Main (default): https://docs.nvidia.com/nemo/gym/main/llms.txt
> - 0.7.0: https://docs.nvidia.com/nemo/gym/v0.7.0/llms.txt
> - 0.6.0: https://docs.nvidia.com/nemo/gym/v0.6.0/llms.txt
> - 0.5.1: https://docs.nvidia.com/nemo/gym/v0.5.1/llms.txt
> - 0.5.0: https://docs.nvidia.com/nemo/gym/v0.5.0/llms.txt
> - 0.4.0: https://docs.nvidia.com/nemo/gym/v0.4.0/llms.txt
> - 0.3.0: https://docs.nvidia.com/nemo/gym/v0.3.0/llms.txt
> - 0.2.1: https://docs.nvidia.com/nemo/gym/v0.2.1/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.nvidia.com/nemo/gym/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.nvidia.com/nemo/gym/_mcp/server.

# nemo_gym.sandbox.providers.docker.pty

Interactive PTY sessions for the Docker provider.

A session is one `docker exec -i [-t]` client process. In PTY mode the client runs under a host
pseudo-terminal of its own, so the docker CLI sees a terminal, allocates one in the container
(`-t`), puts it in raw mode and forwards bytes both ways; the session reads and writes the master
side of that host pty, which `spawn` sizes and puts in raw mode before the client starts, so that
what is written before the client is up waits for it unchanged rather than passing through a cooked line
discipline. The client reads its terminal size at start and again on `SIGWINCH`, which
is how `rows`/`cols` and `resize()` reach the container's terminal. Pipe mode (`pty=False`)
runs the client with plain pipes, so stdout and stderr arrive separately.

Sessions live in this process, not in the sandbox: there is nothing to re-attach to from another
process, and `run_detached` runs attached (there is no proxy connection to spare). Signals are
delivered with `kill` through a second `docker exec`: in PTY mode to the foreground process group of
the session's terminal (what a key press would reach), otherwise to the process group of the session's
process, whose pid the session's launcher writes under `/tmp` inside the container, or under `/dev/shm` when
`/tmp` refuses the write (a read-only root filesystem, `create.read_only: true`, without a tmpfs on `/tmp`;
`/dev/shm` is a writable tmpfs in every container that does not run with `--ipc=none`). A session whose pid can
be recorded in neither place could be neither signalled nor cleaned up, so its launcher says so on stderr (the
terminal in PTY mode) and exits 1 instead of starting the program: `create_pty` itself returns as soon as the
client is spawned, without a round trip, so the failure shows as the session ending at once with that message.

Every in-container command here is one `<shell> -c` line run by the image's shell (bash when it has one, else sh,
which is busybox in Alpine-based images), so the commands stay POSIX and busybox-compatible. In particular `kill`
is spelled `kill -NAME -pgid`, the one form dash, bash and busybox all accept: busybox rejects
`kill -s NAME -- -pgid` (`invalid number '--'`, exit 1 although the signal is delivered) and dash rejects
`kill -s NAME -pgid` (`Illegal option`).

The pid file is written by the session's own user, so anything running as that user can rewrite it, or replace it
by a link to a file it does not own; nothing about the file identifies the process that wrote it. Its content is a
hint, never a kill target by itself: the pid it names is accepted only when the process provably is the one this
session's `docker exec` started, by two properties no process in the container can give another: its environment
carries this session's marker (`NEMO_GYM_PTY_SESSION=<session id>`, set by `docker exec --env` and inherited by
the program the launcher runs) and its parent is not in the container (`PPid` 0 in the container's pid
namespace, true of a process the runtime injected and of nothing the agent can start). The file is read once
(`PID_READ_COMMAND`, which waits up to two seconds for the launcher to write it: `create_pty` returns as
soon as the session's client is spawned, and a `send_signal` or `close` issued right away reaches the container
through a second `docker exec` that races the session's own to the daemon; a record that is not a regular file,
a FIFO or a device the session's user put at the path, is refused at once rather than opened, the file is opened on
a pinned descriptor and checked again there, so a swap of the path after the test cannot hand the read something
that blocks, at most 32 bytes are read, and the exec that reads it has a timeout of its own,
`PID_READ_TIMEOUT_S`, so the session's user can make the record useless but cannot stall `send_signal` or
`close` on it), as the session's own user and not as
root: `/proc/<pid>/environ` is ptrace-gated,
readable by the process's own uid or with `CAP_SYS_PTRACE` in its user namespace, which Docker's default capability
set does not grant, so a root-side read fails for every non-root session, while a read as the session's user reads
the session's process and can vouch for no process of another uid. The record is checked by
`check_recorded_pid` and cached, and `close()` reuses the checked pid. The `kill` and the cleanup run as
root, so they reach the process whatever user the session runs as; no added capability is needed.

## Module Contents

### Classes

| Name                                                                          | Description                                                                                    |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [`DockerPtySession`](#nemo_gym-sandbox-providers-docker-pty-DockerPtySession) | One live terminal: a `docker exec` client under a host pty (or on pipes). Created via `spawn`. |

### Functions

| Name                                                                                    | Description                                                                                                |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| [`_read_fd`](#nemo_gym-sandbox-providers-docker-pty-_read_fd)                           | One chunk from a non-blocking fd; `b""` at EOF (a pty master reports EIO once every slave closed).         |
| [`_winsize`](#nemo_gym-sandbox-providers-docker-pty-_winsize)                           | -                                                                                                          |
| [`_write_fd`](#nemo_gym-sandbox-providers-docker-pty-_write_fd)                         | Write all of `data` to a non-blocking fd without blocking the loop while the pty buffer is full.           |
| [`check_recorded_pid`](#nemo_gym-sandbox-providers-docker-pty-check_recorded_pid)       | The pid `PID_READ_COMMAND` printed, once it is safe to signal as root.                                     |
| [`fallback_pid_file_for`](#nemo_gym-sandbox-providers-docker-pty-fallback_pid_file_for) | Where the launcher records the pid when `pid_file_for`'s `/tmp` refuses the write.                         |
| [`launcher_argv`](#nemo_gym-sandbox-providers-docker-pty-launcher_argv)                 | The program `docker exec` runs: the launcher under `shell` with its pid file and the fallback, then the    |
| [`pid_file_for`](#nemo_gym-sandbox-providers-docker-pty-pid_file_for)                   | -                                                                                                          |
| [`session_marker`](#nemo_gym-sandbox-providers-docker-pty-session_marker)               | The `KEY=value` a session's `docker exec` sets in its process's environment.                               |
| [`spawn`](#nemo_gym-sandbox-providers-docker-pty-spawn)                                 | Start `argv` as a PTY session: under a fresh host pty sized `rows`x`cols` in PTY mode, on pipes otherwise. |

### Data

[`CLEANUP_COMMAND`](#nemo_gym-sandbox-providers-docker-pty-CLEANUP_COMMAND)

[`CLIENT_STDERR_KEEP`](#nemo_gym-sandbox-providers-docker-pty-CLIENT_STDERR_KEEP)

[`DEFAULT_SHELL_SNIPPET`](#nemo_gym-sandbox-providers-docker-pty-DEFAULT_SHELL_SNIPPET)

[`EXEC_PARENT_PID`](#nemo_gym-sandbox-providers-docker-pty-EXEC_PARENT_PID)

[`FALLBACK_PID_FILE_PREFIX`](#nemo_gym-sandbox-providers-docker-pty-FALLBACK_PID_FILE_PREFIX)

[`LAUNCHER`](#nemo_gym-sandbox-providers-docker-pty-LAUNCHER)

[`LOGGER`](#nemo_gym-sandbox-providers-docker-pty-LOGGER)

[`PID_FILE_PREFIX`](#nemo_gym-sandbox-providers-docker-pty-PID_FILE_PREFIX)

[`PID_READ_COMMAND`](#nemo_gym-sandbox-providers-docker-pty-PID_READ_COMMAND)

[`PID_READ_TIMEOUT_S`](#nemo_gym-sandbox-providers-docker-pty-PID_READ_TIMEOUT_S)

[`PID_READ_TRIES`](#nemo_gym-sandbox-providers-docker-pty-PID_READ_TRIES)

[`PID_RECORD_READ`](#nemo_gym-sandbox-providers-docker-pty-PID_RECORD_READ)

[`READ_CHUNK`](#nemo_gym-sandbox-providers-docker-pty-READ_CHUNK)

[`RunInContainer`](#nemo_gym-sandbox-providers-docker-pty-RunInContainer)

[`SESSION_ENV`](#nemo_gym-sandbox-providers-docker-pty-SESSION_ENV)

[`SIGNAL_COMMAND`](#nemo_gym-sandbox-providers-docker-pty-SIGNAL_COMMAND)

[`SUPPORTED_SIGNALS`](#nemo_gym-sandbox-providers-docker-pty-SUPPORTED_SIGNALS)

### API

```python
class nemo_gym.sandbox.providers.docker.pty.DockerPtySession(
    proc: asyncio.subprocess.Process,
    session_id: str,
    mode: str,
    master_fd: int | None,
    run_in_container: nemo_gym.sandbox.providers.docker.pty.RunInContainer | None,
    pid_file: str | None,
    fallback_pid_file: str | None = None,
    runtime_failure: collections.abc.Callable[[str], bool] | None = None,
    client_stderr_fd: int | None = None
)
```

One live terminal: a `docker exec` client under a host pty (or on pipes). Created via `spawn`.

**`_client_stderr`** `= bytearray()`

---

**`_client_stderr_eof`** `Future[None] = loop.create_future()`

---

**`_exit`** `Future[int] = loop.create_future()`

---

**`_fallback_pid_file`** `= fallback_pid_file or pid_file`

---

**`_output`** `Queue[bytes | None] = asyncio.Queue()`

---

**`_pid`** `str | None = None`

---

**`_released`** `Future[None] = loop.create_future()`

---

**`_stderr`** `Queue[bytes | None] = asyncio.Queue()`

---

**`_tasks`** `list[Task[None]] = [asyncio.create_task(self._wait())]`

---

**`_write_lock`** `= asyncio.Lock()`

---

**`_writes`** `set[Future[None]] = set()`

---

**`closed`** `bool`

True once the session can no longer run commands: after `close()` or once the process exited.

---

**`returncode`** `int | None`

---

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.__aenter__() -> nemo_gym.sandbox.providers.docker.pty.DockerPtySession
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.__aexit__(
    exc_type: typing.Any,
    exc_val: typing.Any,
    exc_tb: typing.Any
) -> None
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.__aiter__() -> collections.abc.AsyncIterator[bytes]
```

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._close_error() -> nemo_gym.sandbox.providers.base.SandboxPtyError
```

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._end_local() -> None
```

async

Stop the docker client and release the pty; the in-container process is the caller's business.

A write still waiting for room in the pty is failed first and given the turn it needs to leave (see
`_write_fd`), so the master is closed with no writer and no readiness callback left on its descriptor
number: a session opened right after may get that number for its own pty.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._keep_client_stderr(
    chunk: bytes
) -> None
```

Retain the head of the client's own stderr (`CLIENT_STDERR_KEEP`) for `_startup_failure`.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._pid_files() -> str
```

The pid file and its fallback, quoted for one shell line (the one file when the two are the same).

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._pump_fd(
    fd: int,
    queue: asyncio.Queue[bytes | None] | None,
    close_stderr: bool = False,
    keep: bool = False
) -> None
```

async

Feed the non-blocking `fd` (the pty master, or the pipe of the client's own stderr in PTY mode, which
no reader sees: `queue` is `None` and `keep` retains its head and marks its end) into `queue`.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._pump_stream(
    stream: asyncio.StreamReader | None,
    queue: asyncio.Queue[bytes | None],
    keep: bool = False
) -> None
```

async

Feed `stream` into `queue`; `keep` (the client's stderr in pipe mode, the program's too) retains
its head for `_startup_failure` and marks its end.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._read_stream(
    queue: asyncio.Queue[bytes | None],
    timeout_s: float | None
) -> bytes
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._session_pid() -> str
```

async

The pid the launcher recorded, read once through the provider as the session's user (waiting up to two
seconds for the launcher to record it, refusing at once a record that is not a regular file, under an exec
timeout of `PID_READ_TIMEOUT_S`) and checked (`check_recorded_pid`).

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._startup_failure(
    code: int
) -> str | None
```

Why the client exited without having run the program, or `None` when `code` is the program's exit.

`docker exec` exits 1 with the daemon's refusal on its stderr when the container is paused or gone or the
daemon is unreachable, a code a program may exit with too, so the exit is told by what the client wrote: the
lines the provider's `exec()` recognises as a runtime failure (`runtime_failure`), which make that call
return its runtime sentinel with `error_type="sandbox"`. Here they end the session with `SandboxPtyError`
from `wait_exit()` and `read()` rather than pass the client's code off as the program's. In PTY mode the
client's stderr is its own (the program's goes to the terminal); in pipe mode it is the program's stderr
too, so a program's complaint that reads like the daemon's is classified as `exec()` would classify it.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._wait() -> None
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._write_unlocked(
    data: bytes
) -> None
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.abandon() -> None
```

async

Release the session when its container is going away: no in-container cleanup is attempted.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.close() -> None
```

async

Idempotent: end the process in the container when it still runs, stop the client, drop the pid files.

A docker client that goes away does not end the process it exec'd (the daemon keeps it until it exits),
so a live session's process is ended through a second exec (`CLEANUP_COMMAND`: `SIGHUP` to the group of
the recorded pid, up to a second for a shell to relay it to its jobs, then `SIGKILL` to every process of
that pid's session), by the pid its launcher recorded and `check_recorded_pid` accepted; a session that
exited only has its pid files removed. Both are best-effort: a sandbox that is already gone cannot be reached
and needs no cleanup, and a session whose recorded pid cannot be trusted is left to the container's removal.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.read(
    timeout_s: float | None = None
) -> bytes
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.read_stderr(
    timeout_s: float | None = None
) -> bytes
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.resize(
    rows: int,
    cols: int
) -> None
```

async

Resize the terminal: the host pty's size, then `SIGWINCH` to the docker client, which resizes the
container's. Pipe mode has no terminal and ignores it.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.run_detached(
    command: str,
    poll_interval_s: float = 15.0
) -> tuple[bytes, int | None]
```

async

Run one command and return `(merged output, exit code or None)`.

A Docker session is a local client process, so there is no connection to spare between polls: the
command runs attached, with the same marker discipline as session exec. `poll_interval_s` is accepted
for the protocol and unused.

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.send_signal(
    signal: str
) -> None
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.wait_exit(
    timeout_s: float | None = None
) -> int
```

async

```python
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.write(
    data: bytes
) -> None
```

async

```python
nemo_gym.sandbox.providers.docker.pty._read_fd(
    fd: int
) -> bytes
```

async

One chunk from a non-blocking fd; `b""` at EOF (a pty master reports EIO once every slave closed).

```python
nemo_gym.sandbox.providers.docker.pty._winsize(
    rows: int,
    cols: int
) -> bytes
```

```python
nemo_gym.sandbox.providers.docker.pty._write_fd(
    fd: int,
    data: bytes,
    released: asyncio.Future[None]
) -> None
```

async

Write all of `data` to a non-blocking fd without blocking the loop while the pty buffer is full.

`released` is set when the session starts releasing the fd (`DockerPtySession._end_local`): a write
waiting for room then stops with `SandboxPtyError` as soon as it runs again and never touches the fd after
that, and its readiness callback is gone from the loop before the fd is closed. Otherwise the descriptor
number, free once closed, goes to the next session's pty, and the write would resume into that session's
terminal, or its stale registration would break that session's reads.

```python
nemo_gym.sandbox.providers.docker.pty.check_recorded_pid(
    record: str,
    mode: str,
    session_id: str
) -> str
```

The pid `PID_READ_COMMAND` printed, once it is safe to signal as root.

The pid file belongs to the session's user, who can rewrite it or replace it by a link to any file, so a kill as
root may only follow it to the process this session's `docker exec` started, told by what no process in the
container can give another: a plain pid above 1 whose environment carries this session's marker (the line the
shell matched, `NEMO_GYM_PTY_SESSION=<session id>`; the read ran as the session's user, so the process is of
that user's uid too), whose parent is outside the container (field 4 of
`/proc/<pid>/stat` is `EXEC_PARENT_PID`: a process the runtime injected, not one started inside, so not
a process of the agent's own tree either) and, in PTY mode, which leads its session (field 6 is the pid: the
session leader runc created for this exec). `ValueError` names what is wrong otherwise.

```python
nemo_gym.sandbox.providers.docker.pty.fallback_pid_file_for(
    session_id: str
) -> str
```

Where the launcher records the pid when `pid_file_for`'s `/tmp` refuses the write.

```python
nemo_gym.sandbox.providers.docker.pty.launcher_argv(
    shell: str,
    pid_file: str,
    fallback_pid_file: str,
    command: str | None
) -> list[str]
```

The program `docker exec` runs: the launcher under `shell` with its pid file and the fallback, then the
shell itself or `shell -c command`.

```python
nemo_gym.sandbox.providers.docker.pty.pid_file_for(
    session_id: str
) -> str
```

```python
nemo_gym.sandbox.providers.docker.pty.session_marker(
    session_id: str
) -> str
```

The `KEY=value` a session's `docker exec` sets in its process's environment.

```python
nemo_gym.sandbox.providers.docker.pty.spawn(
    argv: list[str],
    use_pty: bool,
    rows: int,
    cols: int,
    session_id: str | None = None,
    run_in_container: nemo_gym.sandbox.providers.docker.pty.RunInContainer | None = None,
    pid_file: str | None = None,
    fallback_pid_file: str | None = None,
    runtime_failure: collections.abc.Callable[[str], bool] | None = None
) -> nemo_gym.sandbox.providers.docker.pty.DockerPtySession
```

async

Start `argv` as a PTY session: under a fresh host pty sized `rows`x`cols` in PTY mode, on pipes otherwise.

The Docker provider passes a `docker exec` argv; a test may pass any local program, which exercises the same
terminal plumbing without a daemon. `runtime_failure` tells, from the client's own stderr, a client that
exited without having started the program (the provider passes its `exec()` classifier): such an exit ends
the session with `SandboxPtyError` instead of standing as the program's exit code (`_startup_failure`).

```python
nemo_gym.sandbox.providers.docker.pty.CLEANUP_COMMAND = 'rm -f -- {pid_files}; pid={pid}; kill -HUP -"$pid" "$pid" 2>/dev/null; n=0; whi...
```

```python
nemo_gym.sandbox.providers.docker.pty.CLIENT_STDERR_KEEP = 16384
```

```python
nemo_gym.sandbox.providers.docker.pty.DEFAULT_SHELL_SNIPPET = 'exec "$(command -v bash || echo sh)"'
```

```python
nemo_gym.sandbox.providers.docker.pty.EXEC_PARENT_PID = '0'
```

```python
nemo_gym.sandbox.providers.docker.pty.FALLBACK_PID_FILE_PREFIX = '/dev/shm/.nemo-gym-pty-'
```

```python
nemo_gym.sandbox.providers.docker.pty.LAUNCHER = 'f=$0; p=$1; shift; { printf "%s\\n" "$$" > "$f" || printf "%s\\n" "$$" > "$p"; ...
```

```python
nemo_gym.sandbox.providers.docker.pty.LOGGER = logging.getLogger(__name__)
```

```python
nemo_gym.sandbox.providers.docker.pty.PID_FILE_PREFIX = '/tmp/.nemo-gym-pty-'
```

```python
nemo_gym.sandbox.providers.docker.pty.PID_READ_COMMAND = 'f={pid_file}; g={fallback}; n=0; rd() {{ [ -f "$1" ] && ' + PID_RECORD_READ.rep...
```

```python
nemo_gym.sandbox.providers.docker.pty.PID_READ_TIMEOUT_S = 30
```

```python
nemo_gym.sandbox.providers.docker.pty.PID_READ_TRIES = 20
```

```python
nemo_gym.sandbox.providers.docker.pty.PID_RECORD_READ = '{ [ -f /proc/self/fd/3 ] && head -c 32 <&3; } 3<>"$1"'
```

```python
nemo_gym.sandbox.providers.docker.pty.READ_CHUNK = 65536
```

```python
nemo_gym.sandbox.providers.docker.pty.RunInContainer = Callable[..., Awaitable[SandboxExecResult]]
```

```python
nemo_gym.sandbox.providers.docker.pty.SESSION_ENV = 'NEMO_GYM_PTY_SESSION'
```

```python
nemo_gym.sandbox.providers.docker.pty.SIGNAL_COMMAND = 's=$(cat /proc/{pid}/stat 2>/dev/null); s=${{s##*) }}; set -- $s; tp=${{6:-0}}; ...
```

```python
nemo_gym.sandbox.providers.docker.pty.SUPPORTED_SIGNALS = frozenset({'SIGINT', 'SIGTERM', 'SIGKILL', 'SIGQUIT', 'SIGHUP'})
```