Skip to content

feat(exec-runner): the seam for running a target in a chosen environment - #405

Open
raphaelvigee wants to merge 3 commits into
raphaelvigee/exec-runners-designfrom
raphaelvigee/exec-runners-phase0
Open

feat(exec-runner): the seam for running a target in a chosen environment#405
raphaelvigee wants to merge 3 commits into
raphaelvigee/exec-runners-designfrom
raphaelvigee/exec-runners-phase0

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Aug 22, 2026

Copy link
Copy Markdown
Member

Phase 0 of #404. No behaviour change — nothing selects a non-default session yet.

Two commits, and the order matters:

  1. 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.
  2. 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, because runtime_* 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_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) — 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 the Spec. Keeping it there is what lets proc_exec stay exactly as it is:

  • 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 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.
  • PTY allocation stays put. A StdioSpec::Pty variant looked right until it had to carry termiosopenpty(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.

Both spawn and output, deliberately. Six of the eight sites are output, and it cannot be built on spawn: unbounded drain, because nothing consumes the channel until the wait returns. Routing a large go list through spawn + wait wedges on darwin and passes on linux.

SpawnError is typed because two callers match on it: the exec driver, to name which PATH it searched (.hephconfig and a runner are different files to go fix), and the Phase 1 pool, to decide poisoning. io::ErrorKind carries neither. Classification stays in the driver — the only layer that knows which PATH it composed.

ShellFallback forwards the session. Without it, --shell on a driver that doesn't implement run_shell would 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-go and plugin-oci stay on proc_exec until Phase 2 — they are cdylib-only, and an ABI-served driver cannot receive a non-local session until the runner_* fields and their positive ack exist (#411). Converting them now would thread a guaranteed-LocalSession through two large crates and be redone in Phase 2 anyway.

LocalSession::prepare is the identity function, and that is load-bearing: 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 — the honest reading, since the host /usr/bin plus an unhashed path option is the least-pinned environment in the system. It is absent from the key for compatibility, not because it is safe.

Testing

lint clean. Full tst green: 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_gate caught the new crate missing from qualityCrates, which would have left it unformatted and green — fixed here.

Stack created with GitHub Stacks CLIGive Feedback 💬

@raphaelvigee raphaelvigee changed the title raphaelvigee/exec runners phase0 feat(exec-runner): the seam for running a target in a chosen environment Aug 22, 2026
@raphaelvigee
raphaelvigee marked this pull request as ready for review August 23, 2026 11:19
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/exec-runners-phase0 branch from 8decd78 to 58d22b0 Compare August 23, 2026 11:29
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
raphaelvigee force-pushed the raphaelvigee/exec-runners-phase0 branch from 58d22b0 to d147d95 Compare August 23, 2026 23:25
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