Skip to content

feat(ffi): C ABI for streaming-stdio popen — sandlock_popen + handle_kill#120

Merged
congwang-mk merged 2 commits into
multikernel:mainfrom
dzerik:feat/popen-ffi
Jul 2, 2026
Merged

feat(ffi): C ABI for streaming-stdio popen — sandlock_popen + handle_kill#120
congwang-mk merged 2 commits into
multikernel:mainfrom
dzerik:feat/popen-ffi

Conversation

@dzerik

@dzerik dzerik commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Second PR in the streaming-stdio process API series (RFC #67). The core landed
in #118; this exposes it over the C ABI so the language bindings can drive a
confined process's stdio while it runs. The Python/Go wrapper follows as the
next PR in the chain — this lands the C surface it builds on.

What

  • sandlock_popen(policy, name, argv, argc, stdin_mode, stdout_mode, stderr_mode, out_stdin_fd, out_stdout_fd, out_stderr_fd): create+start a live
    handle with per-stream StdioMode (0=inherit, 1=piped, 2=null). Each piped
    stream's owned fd is returned through its out pointer; inherit/null yield -1.
    Uses the multi-threaded live runtime so the supervisor keeps pumping while the
    caller blocks on the fds.
  • sandlock_handle_kill: SIGKILL the handle's process group without freeing the
    handle, so the caller can still collect the exit status via
    sandlock_handle_wait.
  • Existing sandlock_handle_wait/_pid/_free already cover popen handles;
    their safety docs now name sandlock_popen as a second handle source.

Fail-loud / no-leak at the binding boundary

  • An unknown StdioMode returns a null handle with every out fd left -1 and
    logs the rejected discriminant to stderr — not swallowed into an
    indistinguishable null (matching the build_runtime precedent).
  • A piped fd whose out pointer is null is closed rather than leaked.
  • The owned fds are converted to raw ints only on the infallible side of the
    result match, so an error return drops and closes them instead of leaking a
    dangling fd.
  • Because the FFI always takes each piped stream, the core wait()-time stdin
    EOF safety net cannot fire here, so the deadlock warning (close a piped stdin
    and drain piped stdout/stderr before wait) is carried explicitly on the
    sandlock_popen and sandlock_handle_wait docs.

Tests

Drive the symbols directly (no C compilation step): stdout streaming + exit,
stdin/stdout roundtrip through cat, stderr piped, Null yields no fd, unknown
mode rejected with out fds reset, the kill lifecycle (kill→wait→free, kill→free
without wait, idempotent kill), and a destructive fd-leak test — a Piped
stream with a null out pointer must close its fd; the test fails if that close
branch regresses.

Header regenerated with cbindgen (additive, doc-only delta); CI's header-drift
check passes.

Refs #67

…kill

Second piece of the streaming-stdio process API (RFC multikernel#67): expose the core
Sandbox::popen over the C ABI so the Python/Go bindings can drive a confined
process's stdio while it runs. The Python/Go wrapper follows as the next PR in
the series; this lands the C surface it builds on.

- sandlock_popen(policy, name, argv, argc, stdin_mode, stdout_mode,
  stderr_mode, out_stdin_fd, out_stdout_fd, out_stderr_fd): create+start a
  live handle with per-stream StdioMode (0=inherit, 1=piped, 2=null). Each
  piped stream's owned fd is returned through its out pointer; inherit/null
  yield -1. Uses the multi-threaded live runtime so the supervisor keeps
  pumping while the caller blocks on the fds.
- sandlock_handle_kill: SIGKILL the handle's process group without freeing
  the handle, so the caller can still collect the exit status via
  sandlock_handle_wait.
- Existing sandlock_handle_wait/_pid/_free already cover popen handles; their
  safety docs now name sandlock_popen as a second handle source.

Fail-loud / no-leak (the binding boundary): an unknown StdioMode returns a
null handle with every out fd left -1 and logs the rejected discriminant to
stderr (not swallowed into an indistinguishable null); a piped fd whose out
pointer is null is closed rather than leaked. The owned fds are converted to
raw ints only on the infallible side of the result match, so an error return
drops and closes them instead of leaking a dangling fd.

Because the FFI always takes each piped stream, the core wait()-time stdin
EOF safety net cannot fire here, so the deadlock warning (close a piped stdin
and drain piped stdout/stderr before wait) is carried explicitly on the
sandlock_popen and sandlock_handle_wait docs.

Header regenerated with cbindgen (additive only, doc-only delta). Tests drive
the symbols directly: stdout streaming + exit, stdin/stdout roundtrip through
cat, stderr piped, Null yields no fd, unknown mode rejected with out fds reset,
the kill lifecycle (kill->wait->free, kill->free without wait, idempotent kill),
and a destructive fd-leak test (PIPED stream with a null out pointer must close
its fd — verified to fail if the close branch regresses).

Refs multikernel#67
@congwang-mk

Copy link
Copy Markdown
Contributor

Thanks for the contribution! This is a carefully written PR: the up-front reset of the out fds, the fail-loud rejection of unknown modes, and converting the owned fds to raw ints only on the infallible branch (so error returns drop-close them) all land cleanly, and the tests genuinely exercise the streaming paths.

One design note on sandlock_popen:

Duplicated create prologue. sandlock_popen re-inlines almost the entire prologue of sandlock_create_with_runtime: the policy/argv null check, optional_name, read_argv plus arg_refs, the runtime build, and the with_name block. That setup was deliberately factored into sandlock_create_with_runtime (parameterized by a build_rt: fn()) precisely to share it; popen bypasses the helper because it needs sb.popen()'s fds rather than sb.create()'s unit result.

The cost is drift: the next change to policy handling, name validation, or argv parsing (say, optional_name's error path starts logging, or argv gains a length guard) has to be applied in two places, and it is easy to update only one. Extracting a shared prepare(policy, name, argv, argc, build_rt) -> Option<(Sandbox, Runtime, Vec<String>)> that both entry points call would let them converge while popen keeps its own sb.popen() + fd-handoff tail.

Not blocking, just a maintainability cleanup worth considering while the surface is fresh.

sandlock_popen re-inlined almost the entire prologue of
sandlock_create_with_runtime (null check, optional_name, read_argv,
runtime build, with_name), risking drift: a future change to name
validation or argv parsing had to be applied in two places.

Extract prepare(policy, name, argv, argc, build_rt) -> Option<(Sandbox,
Runtime, Vec<String>)> and have both entry points call it. Each keeps its
own tail: create* drive sb.create(), popen drives sb.popen() + fd handoff.

Behavior-preserving: out-fd reset and the fail-loud unknown-StdioMode
rejection stay ahead of the shared prologue in popen. No ABI/header change
(prepare is private). All sandlock-ffi tests green.
@dzerik

dzerik commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Done in 35c06aa. Extracted a shared prepare(policy, name, argv, argc, build_rt) -> Option<(Sandbox, Runtime, Vec<String>)> that both sandlock_create_with_runtime and sandlock_popen call; each keeps its own tail (create() vs popen() + fd hand-off). Behavior-preserving — the out-fd reset and the fail-loud unknown-StdioMode rejection stay ahead of the shared prologue in popen, and there's no ABI/header change (prepare is private). Tests green. Thanks for the suggestion!

@congwang-mk congwang-mk merged commit 766af10 into multikernel:main Jul 2, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants