Liking cljdoc? Tell your friends :D

com.blockether.vis.internal.python-process-handler

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 redirectwhat it meanswhat happens here
PIPEthe guest reads the streampassed through untouched
a stream redirectthe guest named a sinkpiped, drained into that sink
INHERITnobody reads it — THE LEAKpiped, 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.

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.
raw docstring

claim-pid!clj

(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.
sourceraw docstring

contained-handlerclj

(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.
sourceraw docstring

line-sink-streamclj

(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.
sourceraw docstring

log-emitclj

(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.
sourceraw docstring

pid-handoffclj

(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.
sourceraw docstring

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close