Skip to navigation

nemo_gym.sandbox.providers.docker.pty

View as Markdown

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

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

Functions

NameDescription
_read_fdOne chunk from a non-blocking fd; b"" at EOF (a pty master reports EIO once every slave closed).
_winsize-
_write_fdWrite all of data to a non-blocking fd without blocking the loop while the pty buffer is full.
check_recorded_pidThe pid PID_READ_COMMAND printed, once it is safe to signal as root.
fallback_pid_file_forWhere the launcher records the pid when pid_file_for’s /tmp refuses the write.
launcher_argvThe program docker exec runs: the launcher under shell with its pid file and the fallback, then the
pid_file_for-
session_markerThe KEY=value a session’s docker exec sets in its process’s environment.
spawnStart argv as a PTY session: under a fresh host pty sized rowsxcols in PTY mode, on pipes otherwise.

Data

CLEANUP_COMMAND

CLIENT_STDERR_KEEP

DEFAULT_SHELL_SNIPPET

EXEC_PARENT_PID

FALLBACK_PID_FILE_PREFIX

LAUNCHER

LOGGER

PID_FILE_PREFIX

PID_READ_COMMAND

PID_READ_TIMEOUT_S

PID_READ_TRIES

PID_RECORD_READ

READ_CHUNK

RunInContainer

SESSION_ENV

SIGNAL_COMMAND

SUPPORTED_SIGNALS

API

class nemo_gym.sandbox.providers.docker.pty.DockerPtySession(
proc: asyncio.subprocess.Process,
session_id: str,
mode: str,
master_fd: int | 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
async
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.__aexit__(
exc_type: typing.Any,
exc_val: typing.Any,
exc_tb: typing.Any
) -> None
async
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.__aiter__() -> collections.abc.AsyncIterator[bytes]
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._close_error() -> nemo_gym.sandbox.providers.base.SandboxPtyError
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.

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.

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).

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.

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.

nemo_gym.sandbox.providers.docker.pty.DockerPtySession._read_stream(
queue: asyncio.Queue[bytes | None],
timeout_s: float | None
) -> bytes
async
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).

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.

nemo_gym.sandbox.providers.docker.pty.DockerPtySession._wait() -> None
async
nemo_gym.sandbox.providers.docker.pty.DockerPtySession._write_unlocked(
data: bytes
) -> None
async
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.

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.

nemo_gym.sandbox.providers.docker.pty.DockerPtySession.read(
timeout_s: float | None = None
) -> bytes
async
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.read_stderr(
timeout_s: float | None = None
) -> bytes
async
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.

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.

nemo_gym.sandbox.providers.docker.pty.DockerPtySession.send_signal(
signal: str
) -> None
async
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.wait_exit(
timeout_s: float | None = None
) -> int
async
nemo_gym.sandbox.providers.docker.pty.DockerPtySession.write(
data: bytes
) -> None
async
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).

nemo_gym.sandbox.providers.docker.pty._winsize(
rows: int,
cols: int
) -> bytes
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.

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.

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.

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.

nemo_gym.sandbox.providers.docker.pty.pid_file_for(
session_id: str
) -> str
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.

nemo_gym.sandbox.providers.docker.pty.spawn(
argv: list[str],
use_pty: bool,
rows: int,
cols: int,
session_id: str | None = None,
pid_file: str | None = None,
fallback_pid_file: str | None = None,
runtime_failure: collections.abc.Callable[[str], bool] | None = None
async

Start argv as a PTY session: under a fresh host pty sized rowsxcols 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).

nemo_gym.sandbox.providers.docker.pty.CLEANUP_COMMAND = 'rm -f -- {pid_files}; pid={pid}; kill -HUP -"$pid" "$pid" 2>/dev/null; n=0; whi...
nemo_gym.sandbox.providers.docker.pty.CLIENT_STDERR_KEEP = 16384
nemo_gym.sandbox.providers.docker.pty.DEFAULT_SHELL_SNIPPET = 'exec "$(command -v bash || echo sh)"'
nemo_gym.sandbox.providers.docker.pty.EXEC_PARENT_PID = '0'
nemo_gym.sandbox.providers.docker.pty.FALLBACK_PID_FILE_PREFIX = '/dev/shm/.nemo-gym-pty-'
nemo_gym.sandbox.providers.docker.pty.LAUNCHER = 'f=$0; p=$1; shift; { printf "%s\\n" "$$" > "$f" || printf "%s\\n" "$$" > "$p"; ...
nemo_gym.sandbox.providers.docker.pty.LOGGER = logging.getLogger(__name__)
nemo_gym.sandbox.providers.docker.pty.PID_FILE_PREFIX = '/tmp/.nemo-gym-pty-'
nemo_gym.sandbox.providers.docker.pty.PID_READ_COMMAND = 'f={pid_file}; g={fallback}; n=0; rd() {{ [ -f "$1" ] && ' + PID_RECORD_READ.rep...
nemo_gym.sandbox.providers.docker.pty.PID_READ_TIMEOUT_S = 30
nemo_gym.sandbox.providers.docker.pty.PID_READ_TRIES = 20
nemo_gym.sandbox.providers.docker.pty.PID_RECORD_READ = '{ [ -f /proc/self/fd/3 ] && head -c 32 <&3; } 3<>"$1"'
nemo_gym.sandbox.providers.docker.pty.READ_CHUNK = 65536
nemo_gym.sandbox.providers.docker.pty.RunInContainer = Callable[..., Awaitable[SandboxExecResult]]
nemo_gym.sandbox.providers.docker.pty.SESSION_ENV = 'NEMO_GYM_PTY_SESSION'
nemo_gym.sandbox.providers.docker.pty.SIGNAL_COMMAND = 's=$(cat /proc/{pid}/stat 2>/dev/null); s=${{s##*) }}; set -- $s; tp=${{6:-0}}; ...
nemo_gym.sandbox.providers.docker.pty.SUPPORTED_SIGNALS = frozenset({'SIGINT', 'SIGTERM', 'SIGKILL', 'SIGQUIT', 'SIGHUP'})