feat(plugin-abi): exec runners as a first-class plugin component - #418
Open
raphaelvigee wants to merge 5 commits into
Open
feat(plugin-abi): exec runners as a first-class plugin component#418raphaelvigee wants to merge 5 commits into
raphaelvigee wants to merge 5 commits into
Conversation
raphaelvigee
marked this pull request as ready for review
August 23, 2026 13:23
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-generic
branch
from
August 23, 2026 14:27
380823c to
d2b83ff
Compare
This was referenced Aug 23, 2026
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-generic
branch
from
August 23, 2026 16:40
d3f96a8 to
0461458
Compare
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-generic
branch
from
August 23, 2026 17:55
0461458 to
456ec3f
Compare
…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.
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.
…n id `StableExecRunner::open` now returns a live `DynExecSession` instead of a session id, and the id-keyed `open_session`/`prepare_spec`/`close_session` protocol is gone with it. The earlier shape rested on a claim in `exec-runner`'s own docs — "an `ExecSession` is a live object, that cannot cross a stable boundary" — which is false. A cdylib is `dlopen`ed into this process, so a trait object crosses as a fat pointer and stays live on the plugin's side. `ResultOutcome` already does exactly this with `SVec<DynArtifact>`, 240 lines above the trait in the same file. Everything the id bought was a workaround for a boundary that is not there. What goes away: - `ExecRunnerPlugin`, the plugin-side variant of the trait. A cdylib runner now implements the *same* `ExecRunner` a host-side one does, so there is no adapter and no second set of semantics to keep in step. - `PluginExecRunner` / `PluginSession` (the whole of `exec-runner/src/plugin.rs`). - `OpenedSession`, and the `session_id` on every request. - The host-side session registry, and the plugin-side one each runner had to keep to answer by id. What still crosses is only what a *host* caller consumes, and each has one: `prepare` per spawn (the host forks, because `StdioSpec::Fd` owns descriptors wired to the TUI), `base_env` for "which PATH was searched", `max_concurrent` for admission before the worker permit is taken, and the description for `heph inspect`. Those are one struct returned by `open` plus one method. A muxing runner is unaffected in capability and simpler to write: the socket and per-spawn sequence live on the session object, reachable from `prepare` because the host holds the object rather than a key it must hand back. ABI-BREAK-ACK: 0.6.0 is unreleased, so the exec-runner surface is redefined in place rather than bumped again (pre-1.0, per ABI_VERSIONING.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC5ykWRMPjRKGk1Tz1KTja
This commit makes `ExecSession::prepare` async, since it may cross the plugin seam. The go e2e harness implements the trait, so it follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC5ykWRMPjRKGk1Tz1KTja
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-generic
branch
from
August 23, 2026 22:46
456ec3f to
8143c84
Compare
A driver in a cdylib cannot be handed the session its target must run in — a session is a live object and the plugin links its own copy of every type. The previous commit's answer was to send the session's *environment* instead and let the guest rebuild a stand-in from it. That is exact for `Local` and `Env`, and wrong for every session that also rewrites the command. Under `mode = "session"` a plugin driver would run its target on the host with the shell's variables applied and never enter the shell, while still echoing `runner_key` back — walking past the very ack that exists to catch a degraded run. Nothing detects that from the guest side, because the guest has no way to know what it was not sent. So the driver does not create the process. It hands the spec to the host through `StableExecService`, and the host — which holds the real `ExecSession` — applies it in full and returns the output. Nothing about the session crosses, and every runner mode works for every driver. `DynExecService` rides `run` exactly as a `DynExecutor` rides a provider's `list`; that precedent is why it is a parameter rather than a global. The body is spawned on the host runtime via `SeamSpawn`, because this future is polled by a *guest* worker and creating a process touches the host reactor — doing that with no host runtime context panics, and a panic at the extern seam aborts. Two scope calls, both documented at the code: - **No runner selected → `LocalSession` in the guest**, unchanged. The common path stays in-process with no round trip. - **Batch only across the seam.** Nine of the ten plugin-side call sites are batch already; the tenth (`docker_build` showing buildx progress) loses liveness only when it is itself under a runner. A streaming lane can come when something needs it. The end-to-end test earned its place immediately: it failed on the first run with empty stdout, because `ExecSpecPatch` deliberately carries no stdio and the host rebuilt the spec with defaults. The runner lane hides that by re-applying the host's own stdio; this lane has none to re-apply and must be told the kind (`stdio_kind_from_pb`). A unit test on argv would have passed while the feature was broken. ABI-BREAK-ACK: 0.6.0 is unreleased, so this is redefined in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC5ykWRMPjRKGk1Tz1KTja
raphaelvigee
force-pushed
the
raphaelvigee/exec-runners-generic
branch
from
August 24, 2026 08:21
8143c84 to
44b4b0d
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.
On top of #416. Exec runners become a first-class plugin component.
A plugin can now export an exec runner beside its provider, drivers and hooks.
The host asks it to open a session and gets back a live session object; it
then asks that session to transform every spawn's spec. So a runner can hold
one
devenv shellopen and route every target's exec through it, deciding perspawn rather than once per environment. That is the point: how a process starts
belongs to the runner.
Why a component, and not methods on
ManagedDriverThe first draft of this PR rode new
DriverMethodids. That was additive andfree — and the wrong shape. The code said so:
schema. Every runner had to stub
parse/apply_transitive/run— threedead methods per implementor, 22 stub lines across two test doubles alone.
shipping a
dockerrunner meant inventing a dummy driver that builds nothing.its own driver, so a
dockerrunner could not serve targets built bybash.PluginComponentsis the map of what a pluginexports; a runner hidden behind method ids was not in it.
The session is an object, not an id
The second draft got the component right and the session wrong. It defined an
id-keyed protocol —
open_session/prepare_spec(id, …)/close_session(id)— on the premise, written into
exec-runner's own module docs, that "anExecSessionis a live object; that cannot cross a stable boundary."That premise is false, and the counter-example was 240 lines above it in the
same file:
ResultOutcome.artifactsis anSVec<DynArtifact>, live traitobjects returned from a method across this exact seam. A cdylib is
dlopenedinto this process — same address space, same fd table — so a trait object
crosses as a fat pointer and stays live on the plugin's side. The ids were
routing around a boundary that is not there, and the cost landed on every
implementor: each had to keep its own
HashMap<String, Session>so it couldanswer by a name the host had invented for it.
So
openreturns aDynExecSession, and this goes away:ExecRunnerPlugin, the plugin-side variant of the trait. A cdylib runnernow implements the same
ExecRunnera host-side one does — no adapter, nosecond set of semantics to keep in step.
PluginExecRunner/PluginSession— all ofexec-runner/src/plugin.rs.OpenedSession, andsession_idon every request.plugin-devenvshows the effect:Runneris one field again, itsMutex<HashMap<…>>and itsAtomicU64id counter both deleted.A plugin driver does not create its own processes
A driver compiled into heph holds the session and creates its processes. A
driver in a cdylib cannot — same reason as above: a session is a live object and
the plugin links its own copy of every type.
An earlier draft of this PR sent the session's environment to the guest and
had it rebuild a stand-in. That is exact for
LocalandEnvand silentlywrong for anything that also rewrites the command: under
mode = "session"aplugin driver would run its target on the host with the shell's variables
applied, never entering the shell, while still echoing
runner_keyback — pastthe very ack that exists to catch a degraded run.
So the driver asks the host to create the process (
StableExecService), andthe host applies the real session in full. Nothing about the session crosses,
and every runner mode works for every driver —
go_*targets build inside anoci_runnercontainer like anything else.DynExecServiceridesrunexactly as aDynExecutorrides a provider'slist; that precedent is why it is a parameter rather than a global. Its bodyis spawned on the host runtime via
SeamSpawn, because the future is polled bya guest worker and creating a process touches the host reactor — doing that
with no host runtime context panics, and a panic at the extern seam aborts.
Two scope calls, documented at the code:
LocalSessionin the guest. The common path stays in-processwith no round trip.
batch already; the tenth (
docker_buildshowing buildx progress) losesliveness only when itself under a runner.
The end-to-end test earned its place immediately — it failed on the first run
with empty stdout, because
ExecSpecPatchdeliberately carries no stdio and thehost rebuilt the spec with defaults. The runner lane hides that by re-applying
the host's own stdio; this lane has none to re-apply and must be told the kind.
A unit test on argv would have passed while the feature was broken.
The ABI break
StableExecRunner/DynExecRunner/NamedExecRunner/StableExecSession/DynExecSession/OpenOutcome, andPluginComponents.runners.ABI_SEMVER0.5.0 → 0.6.0 — a layout change to the create-entry struct, exactly as
0.3.0's
hookswas, so every plugin is rebuilt against it. All three in-treeplugins declare the new field here, and
scripts/abi-check.shconfirms: "ABIsurface changed; ABI_SEMVER bumped 0.5.0 -> 0.6.0. OK".
0.6.0 is unreleased, so the id-keyed surface is redefined in place rather than
bumped again —
ABI_VERSIONING.mdsanctions that pre-1.0. A load-time hard failis also the right failure mode — loud, versus a plugin silently not serving a
runner. And absence is now structural: an empty
runnersmeans nothing isregistered, rather than a probe that could answer wrong.
The one real cost: a user pinned to an older
heph-go-plugin.jsonfinds thatplugin stops loading entirely after upgrading heph, not just its runners. The
release pipeline publishes matched manifests per version, so that is a
pinned-manifest scenario rather than the default path.
Three things that decided the wire format
stdio never crosses.
StdioSpec::Fdowns a descriptor, and a runner has nobusiness 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.openis cancellable, the session's methods are not.DynExecRunnercomposes
StableCancelbecause a cold devenv evaluation is tens of seconds anda Ctrl-C during it must reach the plugin.
DynExecSessiondoes not:prepareis a fast pure transform on the spawn path and
closeis best-effort, sothere is nothing there worth cancelling.
base_envcarries a known/unknown bit.Noneis not "empty": a container'senvironment lives inside the container, and a caller asking where a PATH entry
came from must degrade explicitly rather than print a confident, wrong answer.
preparebecomes asyncIt may now cross the seam. This does not reopen the window #405 closed: the
only suspension point is before the fork, and fork-through-
Handlestays onesynchronous run of
proc_exec::spawn. The invariant is "nothing awaits betweenfork and
Handle", not "the function is not async" — the module docs assertedthe latter and are corrected here rather than left to mislead.
closejoinsteardownonExecSession: a session inside a plugin can only beclosed by talking to it, while
teardownmust stay sync for the hard-abortpath, which exits without running destructors. There, a plugin-spawned process
is reaped by the host supervisor that tracked it. The SDK's
closehandler runsclosethenteardown, in that order and for the same reasonExecSessionPool::close_alldoes — the host's ownteardownfor a remotesession is
None, so doing half of it would leak the shell until abort-reap.Tests
7 in
plugin-sdk, all crossing the real stabby vtable — a direct call wouldprove nothing about the seam, and the seam is the point:
max_concurrentcrossNone, not emptyThe mux fixture is now the clearest statement of the shape: its socket path and
its per-spawn sequence number live on
MuxSession, reached frompreparebecause the host holds the object rather than a key it must hand back.
exec-runner 28, plugin-sdk 36, e2e/
exec_runner_spec4.lintclean,abi-checkclean.Stack created with GitHub Stacks CLI • Give Feedback 💬