Forcola.Duplex (forcola v0.4.0)

Copy Markdown View Source

A bidirectional stdin/stdout session with an external process.

For interactive CLIs driven over stdin (agent CLIs in stream-json mode). The owner writes lines in and receives lines out; the child runs in its own process group and dies with the session.

{:ok, session} = Forcola.Duplex.open(["claude", "--input-format", "stream-json"], [])
:ok = Forcola.Duplex.send_line(session, json)
receive do
  {:forcola_line, ^session, line} -> line
end
:ok = Forcola.Duplex.close(session)

Messages

The process that called open/2 (the owner) receives:

  • {:forcola_line, session, line} - a stdout line, without its trailing newline. A partial line held across frames is delivered once its newline arrives; a final partial line is delivered before the exit message.
  • {:forcola_stderr, session, line} - a stderr line, unless merge_stderr: true routed stderr into :forcola_line. Under pty: true a terminal carries a single stream, so stderr is always merged into :forcola_line and no :forcola_stderr messages arrive.
  • {:forcola_exit, session, status} - the child exited on its own; status is the exit code, {:signal, n} for death by signal, {:signal, :unconfirmed} when bounded teardown could not prove every process was gone, {:spawn_error, reason} if it never started, or :shim_exited if the shim died without reporting. The session is over; close/1 is not required (but is harmless).

Bounded pull delivery

delivery: :pull replaces owner line messages with recv/2. Each call demands one stdout or stderr line. Both native output pumps start with zero read credit, so an idle or slow consumer stops the pumps, then the child's OS pipes fill and its writes block. At most one recv/2 caller may wait at a time. A pending demand grants at most :max_pending_bytes to each stream; no further credit is granted while complete lines wait in the session queue. The queue and partial-line buffers are therefore bounded by two credit windows plus two partial-line limits and frames already in transit. The native pumps forward at most 8192 bytes per frame. OS pipe capacity is platform-dependent and lies outside the BEAM memory bound.

A line exceeding :max_line_bytes or cumulative output exceeding :max_output_bytes kills the child group. recv/2 returns typed limit evidence in Terminal.output; its status and cleanup confirmation remain separate. An intentional shutdown that discards unread output reports output: :truncated. A transport loss reports output: :unknown. After child exit the pumps stay gated until the caller drains output or calls shutdown/1; await_terminal/2 can therefore wait for a pull consumer that has not finished reading.

Pull mode uses separate stdout and stderr pipes. merge_stderr: true with pipes is rejected because it would hide which pump consumed credit; a pty remains a single merged stream and needs only stdout credit.

Kill discipline

close/1 kills the child's process group (SIGTERM, then SIGKILL after the kill grace) and blocks for the shim's confirmation. shutdown/1 does the same and returns a Forcola.Duplex.Terminal with the observed status, confirmation, and active cleanup scope. await_terminal/2 retrieves that evidence after a spontaneous exit, including after the session process dies. The owner may call forget_terminal/1 when it no longer needs the result; otherwise the small terminal record lives until the owner exits. terminal_recipient: pid also sends {:forcola_terminal, session, terminal} to another process, so an owner supervisor can retain the evidence if the owner dies. The session monitors its owner: owner death takes the same path. If the session process itself is killed brutally, or the whole BEAM dies, the port closes, the shim sees stdin EOF, and the group is killed anyway.

For CLIs that exit when their stdin closes, send_eof/1 closes the child's stdin without killing anything; the child's own exit then arrives as a :forcola_exit message.

Pseudo-terminal

open/2 with pty: true runs the child under a pseudo-terminal instead of pipes. CLIs that detect a tty behave as they do in a real terminal: line buffering rather than block buffering, color output, progress rendering, and interactive prompts (password entry, pagers, REPLs, TUIs).

A terminal carries one bidirectional stream, so a pty merges the child's stdout and stderr: all output arrives as {:forcola_line, ...} and no :forcola_stderr messages are produced. merge_stderr: false contradicts this and raises ArgumentError. An initial window size can be set with :pty_rows and :pty_cols; there is no dynamic resize yet.

Summary

Types

An open duplex session.

Functions

Wait for terminal evidence from a naturally exiting or explicitly closed session. timeout is milliseconds or :infinity. A timeout means only that no result was available yet; it makes no claim about cleanup.

Returns a specification to start this module under a supervisor.

Close the session and kill the child's process group.

Release the retained terminal record after the session has ended.

Open a duplex session running argv; the caller becomes the owner.

Demand one line from an opt-in pull session.

Close the child's stdin without killing the group.

Write a line to the child's stdin.

Close the session and return its terminal evidence. A completed session returns the same stored result on repeated calls; the result is never inferred from the mere absence of a live session process.

Types

session()

@opaque session()

An open duplex session.

Functions

await_terminal(session, timeout \\ :infinity)

@spec await_terminal(session(), timeout()) ::
  {:ok, Forcola.Duplex.Terminal.t()} | {:error, :timeout | :released}

Wait for terminal evidence from a naturally exiting or explicitly closed session. timeout is milliseconds or :infinity. A timeout means only that no result was available yet; it makes no claim about cleanup.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(session)

@spec close(session()) :: :ok

Close the session and kill the child's process group.

Waits for the shim's bounded report and returns :ok for compatibility. This releases any retained terminal record; use shutdown/1 to receive and retain the evidence. Idempotent when the session is already over.

forget_terminal(duplex)

@spec forget_terminal(session()) :: :ok | {:error, :active}

Release the retained terminal record after the session has ended.

open(argv, opts)

@spec open([String.t(), ...], keyword()) :: {:ok, session()} | {:error, term()}

Open a duplex session running argv; the caller becomes the owner.

Options

  • :cd, :env, :merge_stderr - as in Forcola.run/2.
  • :shim_path - trusted absolute shim path, as in Forcola.run/2.
  • :user, :group - run the child as a different user/group, as in Forcola.run/2. POSIX-only, a one-way drop, and requires a privileged shim; failures fail closed and arrive as {:forcola_exit, session, {:spawn_error, reason}}.
  • :cgroup - opt-in Linux cgroup v2 containment of deliberate daemonizers, as in Forcola.run/2. Linux only, requires a delegated cgroup v2 subtree, and falls back to the process-group kill with a warning elsewhere. Default false.
  • :kill_grace_ms - SIGTERM-to-SIGKILL grace, default 5_000.
  • :terminal_recipient - optional process to receive {:forcola_terminal, session, terminal} on every terminal path, including owner death. The recipient must retain the value itself.
  • :pty - run the child under a pseudo-terminal (default false). In pty mode stderr is merged into :forcola_line and no :forcola_stderr messages arrive; passing merge_stderr: false raises ArgumentError.
  • :pty_rows, :pty_cols - initial pty window size, applied only when pty: true.
  • :delivery - :messages (default) or opt-in :pull. Pull mode sends no line messages; call recv/2 to demand a stdout or stderr line.
  • :max_line_bytes, :max_output_bytes, :max_pending_bytes - positive bounds for pull mode. Defaults are 64 KiB, 16 MiB, and one line plus its newline per stream. :max_pending_bytes must exceed :max_line_bytes so a missing newline can be detected without a stall.

There is no :timeout_ms; the session is bounded by its owner process and close/1. Passing :timeout_ms raises ArgumentError.

A spawn failure (e.g. a missing binary) is asynchronous: open/2 still returns {:ok, session} and the failure arrives as {:forcola_exit, session, {:spawn_error, reason}}. Shim path validation and synchronous port startup failures return {:error, reason} immediately, including {:invalid_shim_path, reason} for a bad override. A shim that exits after its port opens is reported through {:forcola_exit, session, :shim_exited}.

recv(session, timeout \\ :infinity)

@spec recv(session(), timeout()) ::
  {:ok, {:stdout | :stderr, binary()}}
  | {:done, Forcola.Duplex.Terminal.t()}
  | {:error,
     :timeout
     | :busy
     | :not_pull
     | :released
     | {:output_limit, Forcola.Duplex.Terminal.t()}}

Demand one line from an opt-in pull session.

Returns {:ok, {:stdout, line}} or {:ok, {:stderr, line}}, then {:done, terminal} after all output has been consumed. A caller that stops consuming should call shutdown/1; that result marks discarded output as truncated. {:error, {:output_limit, terminal}} reports a line or total output limit while retaining the child's status and cleanup result.

send_eof(duplex)

@spec send_eof(session()) :: :ok | {:error, term()}

Close the child's stdin without killing the group.

For CLIs that finish and exit when their input ends; the child's exit then arrives as a :forcola_exit message. Returns {:error, :closed} if the session is already over.

send_line(duplex, line)

@spec send_line(session(), iodata()) :: :ok | {:error, term()}

Write a line to the child's stdin.

A newline is appended. Returns {:error, :closed} once the session is over or the child's stdin has been closed with send_eof/1.

shutdown(session)

@spec shutdown(session()) :: {:ok, Forcola.Duplex.Terminal.t()} | {:error, :released}

Close the session and return its terminal evidence. A completed session returns the same stored result on repeated calls; the result is never inferred from the mere absence of a live session process.