Containment for processes spawned inside an extension's GraalPy context.
Truffle's default process handler starts a child with whatever the guest
did not capture left at Redirect.INHERIT — the JVM's OWN fd 1/2. Under a
foreground vis-agent gateway start that descriptor is the operator's
terminal, so an extension shelling out to a CLI sprays its output (and any
secret in it) onto that terminal and into no log at all. The JVM stream
swap in internal.config/init-cli! cannot reach it: System/setOut and
System/setErr replace PrintStreams, while the child writes to the file
DESCRIPTOR. This handler is therefore the only containment point, and every
extension context is built with it (python-extensions/build-context).
Nothing here is a policy guess — the guest's intent arrives as data on the
ProcessCommand:
| incoming redirect | what it means | what happens here |
|---|---|---|
PIPE | the guest reads the stream | passed through untouched |
| a stream redirect | the guest named a sink | piped, drained into that sink |
INHERIT | nobody reads it — THE LEAK | piped, drained into the log by emit |
Three invariants, each one a way this breaks when dropped:
PIPE without a drainer is worse than the leak it replaces.getInputRedirect is a separate value, so
sudo, ssh, gh auth login and git credential prompts keep the
terminal they need instead of hanging on an invisible prompt.isRedirectErrorStream is honoured, or a stream the guest
deliberately merged comes back split.Popen(stdout=PIPE) followed by wait() is the
classic CPython deadlock — the child fills the OS buffer while the guest
waits for an exit that cannot come — and inside an extension it wedges the
agent, not a script. The backlog decouples the two; past its capacity the
child blocks again, which is exactly the unbuffered behaviour, so a child
streaming without end can never exhaust the heap.The Process handed to the guest answers nullInputStream() for the
streams a drain thread owns, so nothing downstream races the drainer for
bytes.
This handler is only the HOST half. GraalPy discards a stdout=,
stderr= or stdin= file or descriptor before a ProcessCommand is ever
built, so that choice cannot be honoured from here; the guest half that
turns it back into a pumped pipe is vis-python/process_redirect.py,
evaluated into every extension context by python-extensions/build-context
(see python-extensions/redirect-repair-python).
The cost is inherent, not a defect: an uncaptured stream is now a pipe, so
isatty is false for it — progress bars render plain and a genuinely
interactive child cannot work. Capturing output and staying a terminal are
the same choice made two ways.
Containment for processes spawned inside an extension's GraalPy context. Truffle's default process handler starts a child with whatever the guest did not capture left at `Redirect.INHERIT` — the JVM's OWN fd 1/2. Under a foreground `vis-agent gateway start` that descriptor is the operator's terminal, so an extension shelling out to a CLI sprays its output (and any secret in it) onto that terminal and into no log at all. The JVM stream swap in `internal.config/init-cli!` cannot reach it: `System/setOut` and `System/setErr` replace PrintStreams, while the child writes to the file DESCRIPTOR. This handler is therefore the only containment point, and every extension context is built with it (`python-extensions/build-context`). Nothing here is a policy guess — the guest's intent arrives as data on the `ProcessCommand`: | incoming redirect | what it means | what happens here | | ----------------- | ---------------------------- | ------------------------------------- | | `PIPE` | the guest reads the stream | passed through untouched | | a stream redirect | the guest named a sink | piped, drained into that sink | | `INHERIT` | nobody reads it — THE LEAK | piped, drained into the log by `emit` | Three invariants, each one a way this breaks when dropped: - **Every pipe this creates is drained** by a daemon thread. An undrained pipe fills the OS buffer (about 64 KiB) and the child blocks forever; naive `PIPE` without a drainer is worse than the leak it replaces. - **stdin is never rewritten.** `getInputRedirect` is a separate value, so `sudo`, `ssh`, `gh auth login` and git credential prompts keep the terminal they need instead of hanging on an invisible prompt. - **`isRedirectErrorStream` is honoured**, or a stream the guest deliberately merged comes back split. - **A pipe the guest asked for is drained too**, into a bounded backlog the guest then reads from. `Popen(stdout=PIPE)` followed by `wait()` is the classic CPython deadlock — the child fills the OS buffer while the guest waits for an exit that cannot come — and inside an extension it wedges the agent, not a script. The backlog decouples the two; past its capacity the child blocks again, which is exactly the unbuffered behaviour, so a child streaming without end can never exhaust the heap. The `Process` handed to the guest answers `nullInputStream()` for the streams a drain thread owns, so nothing downstream races the drainer for bytes. This handler is only the HOST half. GraalPy discards a `stdout=`, `stderr=` or `stdin=` file or descriptor before a `ProcessCommand` is ever built, so that choice cannot be honoured from here; the guest half that turns it back into a pumped pipe is `vis-python/process_redirect.py`, evaluated into every extension context by `python-extensions/build-context` (see `python-extensions/redirect-repair-python`). The cost is inherent, not a defect: an uncaptured stream is now a pipe, so `isatty` is false for it — progress bars render plain and a genuinely interactive child cannot work. Capturing output and staying a terminal are the same choice made two ways.
(claim-pid! handoff)Take the OS pid handoff holds FOR THIS THREAD, emptying the slot. nil
when nothing has started on this thread through this handler since the last
claim - the guest then keeps the slot index it already has rather than being
handed a guess.
Take the OS pid `handoff` holds FOR THIS THREAD, emptying the slot. `nil` when nothing has started on this thread through this handler since the last claim - the guest then keeps the slot index it already has rather than being handed a guess.
(contained-handler emit handoff)The ProcessHandler every extension context is built with. emit is
(fn [stream-name line]) and receives each line of every stream the guest
left uncaptured; log-emit builds the production one. handoff is the
per-thread slot pid-handoff makes, through which the guest half learns the
real OS pid of the child it just started.
The `ProcessHandler` every extension context is built with. `emit` is `(fn [stream-name line])` and receives each line of every stream the guest left uncaptured; `log-emit` builds the production one. `handoff` is the per-thread slot `pid-handoff` makes, through which the guest half learns the real OS pid of the child it just started.
(line-sink-stream emit)An OutputStream that calls (emit line) once per completed line, plus
once for a trailing partial line on flush/close. Line-oriented because
the destination is a log: a signal per line keeps the extension's name on
every line instead of on an arbitrary buffer boundary. Newlines are not
part of line; a trailing carriage return is dropped.
An `OutputStream` that calls `(emit line)` once per completed line, plus once for a trailing partial line on `flush`/`close`. Line-oriented because the destination is a log: a signal per line keeps the extension's name on every line instead of on an arbitrary buffer boundary. Newlines are not part of `line`; a trailing carriage return is dropped.
(log-emit label)The default sink: one telemere signal per line, tagged with the extension
label and the stream it came from, so output that used to land unattributed
on a terminal lands attributed in the log.
The default sink: one telemere signal per line, tagged with the extension `label` and the stream it came from, so output that used to land unattributed on a terminal lands attributed in the log.
(pid-handoff)A one-slot handoff, CONFINED TO THE STARTING THREAD, carrying the OS pid of
the child a contained-handler most recently started on that thread, from
this handler to the guest that started it.
An extension context runs with allowNativeAccess false, so GraalPy serves
subprocess from its EMULATED posix and never shows the guest an OS pid:
Popen.pid is the per-context CHILD SLOT INDEX PosixResources registered
the child under (1, 2, 3 ...), and that slot is REUSED once the child is
reaped. So the number names no process - 1 is init - and a pid held past
wait() names whichever child later took the slot.
vis-python/process_redirect.py claims this slot inside Popen.__init__
and puts the real pid on the handle.
Per thread, because Popen is not a context's only spawn: GraalPy's
os.system reaches this handler WITHOUT constructing a Popen, and an
extension may call it from another thread (allowCreateThread true). One
shared slot let such a spawn overwrite the pid in the window between the
constructor starting its child and claiming it, so the handle adopted a
stranger's - by then already exited - pid. GraalPy calls
ProcessHandler.start on the very thread the guest spawned from, so a
thread-confined slot pairs start with claim exactly, whatever else the
context spawns meanwhile.
A one-slot handoff, CONFINED TO THE STARTING THREAD, carrying the OS pid of the child a `contained-handler` most recently started on that thread, from this handler to the guest that started it. An extension context runs with `allowNativeAccess false`, so GraalPy serves `subprocess` from its EMULATED posix and never shows the guest an OS pid: `Popen.pid` is the per-context CHILD SLOT INDEX `PosixResources` registered the child under (1, 2, 3 ...), and that slot is REUSED once the child is reaped. So the number names no process - 1 is init - and a pid held past `wait()` names whichever child later took the slot. `vis-python/process_redirect.py` claims this slot inside `Popen.__init__` and puts the real pid on the handle. Per thread, because `Popen` is not a context's only spawn: GraalPy's `os.system` reaches this handler WITHOUT constructing a `Popen`, and an extension may call it from another thread (`allowCreateThread true`). One shared slot let such a spawn overwrite the pid in the window between the constructor starting its child and claiming it, so the handle adopted a stranger's - by then already exited - pid. GraalPy calls `ProcessHandler.start` on the very thread the guest spawned from, so a thread-confined slot pairs start with claim exactly, whatever else the context spawns meanwhile.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |