nemo_gym.sandbox.providers.docker.pty
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
Functions
Data
API
One live terminal: a docker exec client under a host pty (or on pipes). Created via spawn.
True once the session can no longer run commands: after close() or once the process exited.
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.
Retain the head of the client’s own stderr (CLIENT_STDERR_KEEP) for _startup_failure.
The pid file and its fallback, quoted for one shell line (the one file when the two are the same).
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.
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.
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).
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.
Release the session when its container is going away: no in-container cleanup is attempted.
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.
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.
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.
One chunk from a non-blocking fd; b"" at EOF (a pty master reports EIO once every slave closed).
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.
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.
Where the launcher records the pid when pid_file_for’s /tmp refuses the write.
The program docker exec runs: the launcher under shell with its pid file and the fallback, then the
shell itself or shell -c command.
The KEY=value a session’s docker exec sets in its process’s environment.
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).