feat(exec-runner): the seam for running a target in a chosen environment - #405
Open
raphaelvigee wants to merge 3 commits into
Open
feat(exec-runner): the seam for running a target in a chosen environment#405raphaelvigee wants to merge 3 commits into
raphaelvigee wants to merge 3 commits into
Conversation
raphaelvigee
marked this pull request as ready for review
August 23, 2026 11:19
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-phase0
branch
from
August 23, 2026 11:29
8decd78 to
58d22b0
Compare
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
A plugin can now export an **exec runner** beside its provider, drivers and hooks. The host asks it to open a session, to transform **every spawn's** spec, and to close the session — so a runner can hold one `devenv shell` open and route every target's exec through it, deciding per spawn rather than once per environment. That is the point: process creation belongs to the runner. ## Why a component, and not methods on `ManagedDriver` The first draft of this rode new `DriverMethod` ids, which was additive and free. It was also the wrong shape, and the code said so: - **A runner is not a driver.** It does not parse, build, or carry a config schema. Every runner had to stub `parse` / `apply_transitive` / `run` — three dead methods per implementor, 22 stub lines across two test doubles alone. - **A runner-only plugin was impossible.** Those methods are defaultless, so shipping a `docker` runner meant inventing a dummy driver that builds nothing. - **A runner's name was a driver's name.** A cdylib could only serve runners for its own driver, so a `docker` runner could not serve targets built by `bash`. - **It was undiscoverable.** `PluginComponents` is the map of what a plugin exports; a runner hidden behind method ids was not in it. So: `StableExecRunner` / `DynExecRunner` / `NamedExecRunner`, and `PluginComponents.runners`. `ABI_SEMVER` 0.5.0 -> 0.6.0 — a layout change to the create-entry struct, exactly as 0.3.0's `hooks` was, so every plugin is rebuilt against it. Pre-1.0 the versioning doc sanctions this, and all three in-tree plugins declare the new field here. A load-time hard fail is also the right failure mode: loud, versus a plugin silently not serving a runner. And absence is now structural — an empty `runners` means nothing is registered, rather than a probe that could answer wrong. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that the guest's defaults come back and silently replace a PTY slave with `Null`. **`open_session` is cancellable.** `DynExecRunner` composes `StableCancel` because a cold devenv evaluation is tens of seconds, and a Ctrl-C during it must reach the plugin rather than wait it out. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## `prepare` becomes async It may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs asserted the latter and are corrected rather than left to mislead. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, while `teardown` must stay sync for the hard-abort path, which exits without running destructors. There, a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable — a direct call would prove nothing about the seam, and the seam is the point: the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on every spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a runner serves with no driver anywhere in the picture. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128. lint clean.
The exec-runner work (docs/EXEC_RUNNERS.md) inserts a session's `base_env` beneath the driver's PATH. Everything else about the composition sequence must stay byte-identical, and nothing asserted that: the only existing freeze is `hashin`, which cannot see an env reordering because `runtime_*` values are deliberately unhashed. A change there would alter what existing targets see under an unchanged cache key. Freezes the two properties a layer table cannot express, both of which an early draft of the design got backwards: - `pass_env` / `runtime_pass_env` / `runtime_env` are applied AFTER the heph-computed vars, so they override `WORKSPACE_ROOT` / `OUT_*`. - `env` is applied BEFORE them, so `OUT_*` APPENDS to an `env`-supplied value rather than replacing it — `entry().or_default()`, not `insert`. Plus `runtime_env` beating `pass_env` for one key, and the absence of any ambient leak. This is assertable at all only because the spawn is `env_clear`ed: the child's environment is exactly what the driver put there, so the only entries to filter are the four bash sets for itself. No production change.
Phase 0 of docs/EXEC_RUNNERS.md: introduce `ExecSession` and route target process creation through it, so a later phase can choose the environment a target's processes are born into. Nothing selects a non-default session yet, so behaviour is unchanged — the env golden in the parent commit is what makes that claim checkable rather than asserted. `prepare(spec) -> Spec` is the seam, not a trait-objectified process factory. For the two modes that matter first — a devenv env snapshot, and a container or chroot — a session is a pure transformation of the `Spec`: merge a base environment underneath the caller's own, prepend an argv prefix. Keeping it there is what lets `proc_exec` stay exactly as it is: - `proc_exec::spawn` stays synchronous. There is no suspension point between the fork and the caller receiving the `Handle`, so a cancellation cannot land there and orphan a child that was never registered with the supervisor. An `async fn spawn` would open exactly that window, and under fail-fast it is the common cancellation shape. - `Handle`'s "the spawn is the API" invariant and its OS-divergent reader-termination discipline stay concrete rather than erased behind a trait object. - PTY allocation stays put. A `StdioSpec::Pty` variant looked right until it had to carry `termios` — `openpty(3)` on macOS leaves the slave's termios unspecified, which is why `pty::inherit_termios` exists — and until it needed a return path for the master. What has to move for a non-forking runner is fd transport, not pty allocation. `ExecSession` exposes both `spawn` and `output` deliberately. Six of the eight process-creation sites are `output`, and it cannot be built on `spawn`: it drains with `DrainCapacity::Unbounded` because nothing consumes the channel until the wait returns, where `spawn` is bounded at 512 KiB. A `go list` emitting more than that, routed through `spawn` + wait, wedges on darwin and passes on linux. `SpawnError` is typed because two callers match on the outcome: the exec driver, to name *which* PATH it searched (`.hephconfig` and the runner are different files to go fix), and, in Phase 1, the session pool to decide poisoning. `io::ErrorKind` carries neither — it has no variant for "the session is gone". Classification stays in the driver, which is the only layer that knows which PATH it composed. `ShellFallback` forwards the session to its synthetic target. Without that, `--shell` on a driver that does not implement `run_shell` would drop the user into a host shell while claiming to show what the target sees. Converts the statically-linked sites (`plugin-exec`, `plugin-nix`). The cdylib sites (`plugin-go`, `plugin-oci`) stay on `proc_exec` until Phase 2: an ABI-served driver cannot receive a non-local session until the `runner_*` fields and their positive ack exist, so converting them now would thread a guaranteed-`LocalSession` through two large crates and be redone in Phase 2 anyway. The guest constructs its own local session for the same reason. `LocalSession::prepare` is the identity function, and that is load-bearing rather than lazy: `local` contributes nothing to any cache key, which is sound only while it is byte-for-byte the previous behaviour. Its `Identity` is recorded as `Asserted`, which is the honest reading — the host `/usr/bin` plus a `path` option that is not hashed is the least-pinned environment in the system; it is absent from the key for compatibility, not because it is safe. Registers the new crate in `qualityCrates` — the repo's own lint gate caught that omission, which would otherwise have left it unformatted and green.
Caught by CI's `Lint linux/amd64`, not locally: `lint` is a devenv-provided script whose `qualityCrates` list is baked into the shell at start, so the running shell was still using the pre-`exec-runner` list and `cargo fmt --check` never looked at the new crate. CI starts fresh and did. Exactly the hole `tests/lint_gate.rs::the_fmt_pass_covers_every_workspace_member` exists to close — it made the list correct; only this shell was stale.
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
…process A runner target's driver can now serve the environment it describes: the host asks it to open a session, to transform every spawn's spec, and to close the session. `prepare` is per target, so a runner can hold one `devenv shell` open and route every target's exec through it — deciding per spawn, not once per environment. That is the point: process creation belongs to the runner. Additive — three new `DriverMethod` ids, no `abi.rs` change and no ABI_SEMVER bump, the same lane #411 used for `runner_env`. ## Shape - `ManagedDriver` gains `serves_exec_sessions` / `open_session` / `prepare_spec` / `close_session`, defaulting off exactly like `supports_shell`. - `DriverExecRunner` adapts any session-serving `ManagedDriver` into an `ExecRunner`. Deliberately generic over the trait rather than over the ABI, so one implementation covers an in-process driver and a cdylib one — a cdylib's host-side wrapper *is* a `ManagedDriver`. - A loaded plugin whose driver serves sessions is registered as the runner for its own name, which is how a runner target's driver already selects its runner. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that, the guest's defaults would come back and silently replace a PTY slave with `Null`. **The capability probe is synchronous.** It rides the `meta` lane, because the host must know at registration time — before any target runs — whether a runner naming this driver can be opened. A plugin built before this lane returns empty for an unknown method id, which reads as "no", and the host then **refuses**. Degrading to a local environment would build the target in the host's under a key asserting the runner's, and push that to the shared remote cache. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## Async prepare `ExecSession::prepare` and `spawn` become `async`, since `prepare` may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs said the latter and are corrected. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, and `teardown` must stay sync for the hard-abort path, which exits without running destructors. On abort a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable (a direct call would prove nothing about the seam): the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on *every* spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a pre-lane plugin is refused rather than degraded. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128, e2e 143. lint clean.
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
A plugin can now export an **exec runner** beside its provider, drivers and hooks. The host asks it to open a session, to transform **every spawn's** spec, and to close the session — so a runner can hold one `devenv shell` open and route every target's exec through it, deciding per spawn rather than once per environment. That is the point: process creation belongs to the runner. ## Why a component, and not methods on `ManagedDriver` The first draft of this rode new `DriverMethod` ids, which was additive and free. It was also the wrong shape, and the code said so: - **A runner is not a driver.** It does not parse, build, or carry a config schema. Every runner had to stub `parse` / `apply_transitive` / `run` — three dead methods per implementor, 22 stub lines across two test doubles alone. - **A runner-only plugin was impossible.** Those methods are defaultless, so shipping a `docker` runner meant inventing a dummy driver that builds nothing. - **A runner's name was a driver's name.** A cdylib could only serve runners for its own driver, so a `docker` runner could not serve targets built by `bash`. - **It was undiscoverable.** `PluginComponents` is the map of what a plugin exports; a runner hidden behind method ids was not in it. So: `StableExecRunner` / `DynExecRunner` / `NamedExecRunner`, and `PluginComponents.runners`. `ABI_SEMVER` 0.5.0 -> 0.6.0 — a layout change to the create-entry struct, exactly as 0.3.0's `hooks` was, so every plugin is rebuilt against it. Pre-1.0 the versioning doc sanctions this, and all three in-tree plugins declare the new field here. A load-time hard fail is also the right failure mode: loud, versus a plugin silently not serving a runner. And absence is now structural — an empty `runners` means nothing is registered, rather than a probe that could answer wrong. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that the guest's defaults come back and silently replace a PTY slave with `Null`. **`open_session` is cancellable.** `DynExecRunner` composes `StableCancel` because a cold devenv evaluation is tens of seconds, and a Ctrl-C during it must reach the plugin rather than wait it out. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## `prepare` becomes async It may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs asserted the latter and are corrected rather than left to mislead. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, while `teardown` must stay sync for the hard-abort path, which exits without running destructors. There, a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable — a direct call would prove nothing about the seam, and the seam is the point: the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on every spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a runner serves with no driver anywhere in the picture. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128. lint clean.
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
…process A runner target's driver can now serve the environment it describes: the host asks it to open a session, to transform every spawn's spec, and to close the session. `prepare` is per target, so a runner can hold one `devenv shell` open and route every target's exec through it — deciding per spawn, not once per environment. That is the point: process creation belongs to the runner. Additive — three new `DriverMethod` ids, no `abi.rs` change and no ABI_SEMVER bump, the same lane #411 used for `runner_env`. ## Shape - `ManagedDriver` gains `serves_exec_sessions` / `open_session` / `prepare_spec` / `close_session`, defaulting off exactly like `supports_shell`. - `DriverExecRunner` adapts any session-serving `ManagedDriver` into an `ExecRunner`. Deliberately generic over the trait rather than over the ABI, so one implementation covers an in-process driver and a cdylib one — a cdylib's host-side wrapper *is* a `ManagedDriver`. - A loaded plugin whose driver serves sessions is registered as the runner for its own name, which is how a runner target's driver already selects its runner. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that, the guest's defaults would come back and silently replace a PTY slave with `Null`. **The capability probe is synchronous.** It rides the `meta` lane, because the host must know at registration time — before any target runs — whether a runner naming this driver can be opened. A plugin built before this lane returns empty for an unknown method id, which reads as "no", and the host then **refuses**. Degrading to a local environment would build the target in the host's under a key asserting the runner's, and push that to the shared remote cache. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## Async prepare `ExecSession::prepare` and `spawn` become `async`, since `prepare` may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs said the latter and are corrected. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, and `teardown` must stay sync for the hard-abort path, which exits without running destructors. On abort a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable (a direct call would prove nothing about the seam): the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on *every* spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a pre-lane plugin is refused rather than degraded. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128, e2e 143. lint clean.
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
A plugin can now export an **exec runner** beside its provider, drivers and hooks. The host asks it to open a session, to transform **every spawn's** spec, and to close the session — so a runner can hold one `devenv shell` open and route every target's exec through it, deciding per spawn rather than once per environment. That is the point: process creation belongs to the runner. ## Why a component, and not methods on `ManagedDriver` The first draft of this rode new `DriverMethod` ids, which was additive and free. It was also the wrong shape, and the code said so: - **A runner is not a driver.** It does not parse, build, or carry a config schema. Every runner had to stub `parse` / `apply_transitive` / `run` — three dead methods per implementor, 22 stub lines across two test doubles alone. - **A runner-only plugin was impossible.** Those methods are defaultless, so shipping a `docker` runner meant inventing a dummy driver that builds nothing. - **A runner's name was a driver's name.** A cdylib could only serve runners for its own driver, so a `docker` runner could not serve targets built by `bash`. - **It was undiscoverable.** `PluginComponents` is the map of what a plugin exports; a runner hidden behind method ids was not in it. So: `StableExecRunner` / `DynExecRunner` / `NamedExecRunner`, and `PluginComponents.runners`. `ABI_SEMVER` 0.5.0 -> 0.6.0 — a layout change to the create-entry struct, exactly as 0.3.0's `hooks` was, so every plugin is rebuilt against it. Pre-1.0 the versioning doc sanctions this, and all three in-tree plugins declare the new field here. A load-time hard fail is also the right failure mode: loud, versus a plugin silently not serving a runner. And absence is now structural — an empty `runners` means nothing is registered, rather than a probe that could answer wrong. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that the guest's defaults come back and silently replace a PTY slave with `Null`. **`open_session` is cancellable.** `DynExecRunner` composes `StableCancel` because a cold devenv evaluation is tens of seconds, and a Ctrl-C during it must reach the plugin rather than wait it out. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## `prepare` becomes async It may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs asserted the latter and are corrected rather than left to mislead. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, while `teardown` must stay sync for the hard-abort path, which exits without running destructors. There, a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable — a direct call would prove nothing about the seam, and the seam is the point: the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on every spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a runner serves with no driver anywhere in the picture. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128. lint clean.
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
…process A runner target's driver can now serve the environment it describes: the host asks it to open a session, to transform every spawn's spec, and to close the session. `prepare` is per target, so a runner can hold one `devenv shell` open and route every target's exec through it — deciding per spawn, not once per environment. That is the point: process creation belongs to the runner. Additive — three new `DriverMethod` ids, no `abi.rs` change and no ABI_SEMVER bump, the same lane #411 used for `runner_env`. ## Shape - `ManagedDriver` gains `serves_exec_sessions` / `open_session` / `prepare_spec` / `close_session`, defaulting off exactly like `supports_shell`. - `DriverExecRunner` adapts any session-serving `ManagedDriver` into an `ExecRunner`. Deliberately generic over the trait rather than over the ABI, so one implementation covers an in-process driver and a cdylib one — a cdylib's host-side wrapper *is* a `ManagedDriver`. - A loaded plugin whose driver serves sessions is registered as the runner for its own name, which is how a runner target's driver already selects its runner. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that, the guest's defaults would come back and silently replace a PTY slave with `Null`. **The capability probe is synchronous.** It rides the `meta` lane, because the host must know at registration time — before any target runs — whether a runner naming this driver can be opened. A plugin built before this lane returns empty for an unknown method id, which reads as "no", and the host then **refuses**. Degrading to a local environment would build the target in the host's under a key asserting the runner's, and push that to the shared remote cache. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## Async prepare `ExecSession::prepare` and `spawn` become `async`, since `prepare` may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs said the latter and are corrected. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, and `teardown` must stay sync for the hard-abort path, which exits without running destructors. On abort a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable (a direct call would prove nothing about the seam): the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on *every* spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a pre-lane plugin is refused rather than degraded. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128, e2e 143. lint clean.
raphaelvigee
added a commit
that referenced
this pull request
Aug 23, 2026
A plugin can now export an **exec runner** beside its provider, drivers and hooks. The host asks it to open a session, to transform **every spawn's** spec, and to close the session — so a runner can hold one `devenv shell` open and route every target's exec through it, deciding per spawn rather than once per environment. That is the point: process creation belongs to the runner. ## Why a component, and not methods on `ManagedDriver` The first draft of this rode new `DriverMethod` ids, which was additive and free. It was also the wrong shape, and the code said so: - **A runner is not a driver.** It does not parse, build, or carry a config schema. Every runner had to stub `parse` / `apply_transitive` / `run` — three dead methods per implementor, 22 stub lines across two test doubles alone. - **A runner-only plugin was impossible.** Those methods are defaultless, so shipping a `docker` runner meant inventing a dummy driver that builds nothing. - **A runner's name was a driver's name.** A cdylib could only serve runners for its own driver, so a `docker` runner could not serve targets built by `bash`. - **It was undiscoverable.** `PluginComponents` is the map of what a plugin exports; a runner hidden behind method ids was not in it. So: `StableExecRunner` / `DynExecRunner` / `NamedExecRunner`, and `PluginComponents.runners`. `ABI_SEMVER` 0.5.0 -> 0.6.0 — a layout change to the create-entry struct, exactly as 0.3.0's `hooks` was, so every plugin is rebuilt against it. Pre-1.0 the versioning doc sanctions this, and all three in-tree plugins declare the new field here. A load-time hard fail is also the right failure mode: loud, versus a plugin silently not serving a runner. And absence is now structural — an empty `runners` means nothing is registered, rather than a probe that could answer wrong. ## Three things that decided the wire format **stdio never crosses.** `StdioSpec::Fd` owns a descriptor, and a runner has no business reassigning the host's PTY slave. Only the fields a runner may change round-trip; the host keeps the real stdio and re-applies it. Without that the guest's defaults come back and silently replace a PTY slave with `Null`. **`open_session` is cancellable.** `DynExecRunner` composes `StableCancel` because a cold devenv evaluation is tens of seconds, and a Ctrl-C during it must reach the plugin rather than wait it out. **`base_env` carries a known/unknown bit.** `None` is not "empty": a container's environment lives inside the container, and a caller asking where a PATH entry came from must degrade explicitly rather than print a confident, wrong answer. ## `prepare` becomes async It may now cross the seam. This does not reopen the window #405 closed: the only suspension point is *before* the fork, and fork-through-`Handle` stays one synchronous run of `proc_exec::spawn`. The invariant is "nothing awaits between fork and `Handle`", not "the function is not async" — the module docs asserted the latter and are corrected rather than left to mislead. `close` joins `teardown` on `ExecSession`: a session inside a plugin can only be closed by talking to it, while `teardown` must stay sync for the hard-abort path, which exits without running destructors. There, a plugin-spawned process is reaped by the host supervisor that tracked it. ## Tests 7 in `plugin-sdk`, all crossing the real stabby vtable — a direct call would prove nothing about the seam, and the seam is the point: the spawn is rewritten to run the runner's client with the real program demoted to an argument; the runner is reached on every spawn while the environment opens once; host-owned stdio survives; caps and `max_concurrent` cross; unenumerable stays `None`; close is idempotent; and a runner serves with no driver anywhere in the picture. exec-runner 28, driver-support 39, plugin-sdk 36, plugin-exec 91, engine 545, heph 128. lint clean.
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-phase0
branch
from
August 23, 2026 23:25
58d22b0 to
d147d95
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 0 of #404. No behaviour change — nothing selects a non-default session yet.
Two commits, and the order matters:
test(plugin-exec): freeze the composed child environment— the golden. Check this commit out on its own and it is green without the seam; that is what makes commit 2's inertness checkable rather than asserted.feat(exec-runner): the seam— the change. The golden still passes, unmodified.Why a golden first
The only existing freeze is
hashin, and it cannot see an env reordering, becauseruntime_*values are deliberately unhashed. So a change to env composition would alter what existing targets see under an unchanged cache key — silently. Writing the golden also caught two places where an early draft had the precedence order backwards:pass_env/runtime_pass_env/runtime_envare applied after the heph-computed vars, so they overrideWORKSPACE_ROOT/OUT_*.envis applied before them, soOUT_*appends to anenv-supplied value rather than replacing it (entry().or_default(), notinsert) — a merge no layer table can express.The seam
prepare(spec) -> Spec, not a trait-objectified process factory. For the modes that matter first — a devenv env snapshot, a container, a chroot — a session is a pure transformation of theSpec. Keeping it there is what letsproc_execstay exactly as it is:spawnstays synchronous. There is no suspension point between the fork and the caller receiving theHandle, so a cancellation cannot land there and orphan a child that was never registered with the supervisor. Anasync fn spawnwould open exactly that window — and under fail-fast that is the common cancellation shape, not an exotic one.Handle's "the spawn is the API" invariant and its OS-divergent reader-termination discipline stay concrete rather than erased behind a trait object.StdioSpec::Ptyvariant looked right until it had to carrytermios—openpty(3)on macOS leaves the slave's termios unspecified, which is whypty::inherit_termiosexists — and until it needed a return path for the master.Both
spawnandoutput, deliberately. Six of the eight sites areoutput, and it cannot be built onspawn: unbounded drain, because nothing consumes the channel until the wait returns. Routing a largego listthroughspawn+ wait wedges on darwin and passes on linux.SpawnErroris typed because two callers match on it: the exec driver, to name which PATH it searched (.hephconfigand a runner are different files to go fix), and the Phase 1 pool, to decide poisoning.io::ErrorKindcarries neither. Classification stays in the driver — the only layer that knows which PATH it composed.ShellFallbackforwards the session. Without it,--shellon a driver that doesn't implementrun_shellwould drop you into a host shell while claiming to show what the target sees.Scope
Converts the statically-linked sites (
plugin-exec,plugin-nix).plugin-goandplugin-ocistay onproc_execuntil Phase 2 — they are cdylib-only, and an ABI-served driver cannot receive a non-local session until therunner_*fields and their positive ack exist (#411). Converting them now would thread a guaranteed-LocalSessionthrough two large crates and be redone in Phase 2 anyway.LocalSession::prepareis the identity function, and that is load-bearing:localcontributes nothing to any cache key, which is sound only while it is byte-for-byte the previous behaviour. ItsIdentityis recorded asAsserted— the honest reading, since the host/usr/binplus an unhashedpathoption is the least-pinned environment in the system. It is absent from the key for compatibility, not because it is safe.Testing
lintclean. Fulltstgreen: 481 e2e, 183 engine, 91 plugin-exec (incl. the golden and the missing-program diagnostic), 102 plugin-go, 39 plugin-nix, 15 driver-bridge.The repo's own
lint_gatecaught the new crate missing fromqualityCrates, which would have left it unformatted and green — fixed here.Stack created with GitHub Stacks CLI • Give Feedback 💬