Skip to content

feat(plugin-abi): exec runners as a first-class plugin component - #418

Open
raphaelvigee wants to merge 5 commits into
raphaelvigee/exec-runners-envfrom
raphaelvigee/exec-runners-generic
Open

feat(plugin-abi): exec runners as a first-class plugin component#418
raphaelvigee wants to merge 5 commits into
raphaelvigee/exec-runners-envfrom
raphaelvigee/exec-runners-generic

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Aug 23, 2026

Copy link
Copy Markdown
Member

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 shell open and route every target's exec through it, deciding per
spawn rather than once per environment. That is the point: how a process starts
belongs to the runner.

Why a component, and not methods on ManagedDriver

The first draft of this PR rode new DriverMethod ids. That was additive and
free — and the wrong shape. 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.

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 "an
ExecSession is 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.artifacts is an SVec<DynArtifact>, live trait
objects returned from a method across this exact seam. A cdylib is dlopened
into 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 could
answer by a name the host had invented for it.

So open returns a DynExecSession, and this goes away:

  • ExecRunnerPlugin, the plugin-side variant of the trait. A cdylib runner
    now implements the same ExecRunner a host-side one does
    — no adapter, no
    second set of semantics to keep in step.
  • PluginExecRunner / PluginSession — all of exec-runner/src/plugin.rs.
  • OpenedSession, and session_id on every request.
  • The host-side session registry, and the plugin-side one each runner kept.

plugin-devenv shows the effect: Runner is one field again, its
Mutex<HashMap<…>> and its AtomicU64 id 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 Local and Env and silently
wrong for anything that also rewrites the command: under mode = "session" a
plugin driver would run its target on the host with the shell's variables
applied, never entering the shell, while still echoing runner_key back — past
the very ack that exists to catch a degraded run.

So the driver asks the host to create the process (StableExecService), and
the host applies the real session in full. Nothing about the session crosses,
and every runner mode works for every driver — go_* targets build inside an
oci_runner container like anything else.

DynExecService rides run exactly as a DynExecutor rides a provider's
list; that precedent is why it is a parameter rather than a global. Its body
is spawned on the host runtime via SeamSpawn, because the 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, documented at the code:

  • No runner → LocalSession in the guest. 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 itself under a runner.

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.
A unit test on argv would have passed while the feature was broken.

The ABI break

StableExecRunner / DynExecRunner / NamedExecRunner / StableExecSession /
DynExecSession / OpenOutcome, 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. All three in-tree
plugins declare the new field here, and scripts/abi-check.sh confirms: "ABI
surface 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.md sanctions that pre-1.0. 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.

The one real cost: a user pinned to an older heph-go-plugin.json finds that
plugin 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::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 is cancellable, the session's methods are not. DynExecRunner
composes StableCancel because a cold devenv evaluation is tens of seconds and
a Ctrl-C during it must reach the plugin. DynExecSession does not: prepare
is a fast pure transform on the spawn path and close is best-effort, so
there is nothing there worth cancelling.

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 here 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. The SDK's close handler runs
close then teardown, in that order and for the same reason
ExecSessionPool::close_all does — the host's own teardown for a remote
session 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 would
prove nothing about the seam, and the seam is the point:

the spawn is rewritten to run the runner's client, real program demoted to an argument PASS
the runner is reached on every spawn while the environment opens once PASS
host-owned stdio survives the round trip PASS
caps and max_concurrent cross PASS
an unenumerable environment stays None, not empty PASS
close is idempotent PASS
a runner serves with no driver anywhere — the capability the break bought PASS

The mux fixture is now the clearest statement of the shape: its socket path and
its per-spawn sequence number live on MuxSession, reached from prepare
because the host holds the object rather than a key it must hand back.

exec-runner 28, plugin-sdk 36, e2e/exec_runner_spec 4. lint clean,
abi-check clean.

Stack created with GitHub Stacks CLIGive Feedback 💬

@raphaelvigee
raphaelvigee marked this pull request as ready for review August 23, 2026 13:23
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/exec-runners-generic branch from 380823c to d2b83ff Compare August 23, 2026 14:27
@raphaelvigee raphaelvigee changed the title refactor(exec-runner): make the runner generic, so a runner plugin ships only a driver feat(plugin-abi): an exec-runner lane, so a runner plugin starts the process Aug 23, 2026
@raphaelvigee raphaelvigee changed the title feat(plugin-abi): an exec-runner lane, so a runner plugin starts the process feat(plugin-abi): exec runners as a first-class plugin component Aug 23, 2026
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/exec-runners-generic branch from d3f96a8 to 0461458 Compare August 23, 2026 16:40
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/exec-runners-generic branch from 0461458 to 456ec3f Compare August 23, 2026 17:55
raphaelvigee and others added 4 commits August 24, 2026 00:45
…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
raphaelvigee force-pushed the raphaelvigee/exec-runners-generic branch from 456ec3f to 8143c84 Compare August 23, 2026 22:46
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
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.

1 participant