Skip to content

feat: external + in-process plugins (4 transports incl. native dylib)#86

Draft
raphaelvigee wants to merge 44 commits into
masterfrom
worktree-bridge-cse_012bgor9esH5wYHtN3svxV2U
Draft

feat: external + in-process plugins (4 transports incl. native dylib)#86
raphaelvigee wants to merge 44 commits into
masterfrom
worktree-bridge-cse_012bgor9esH5wYHtN3svxV2U

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Jun 15, 2026

Copy link
Copy Markdown
Member

Make heph plugins runnable as separately-built, hot-swappable artifacts over four transports behind one logical interface — out-of-process for isolation/polyglot, in-process for native speed.

Transport Where Cost Use
proto (bin:) out-of-process, UDS ~µs/call portable, polyglot, OS-sandboxable
shm out-of-process, iceoryx2 low syscall high-volume out-of-process
wasm (wasm:) in-process, wasmtime host-call pure-compute, capability-sandboxed
dylib (dylib:) in-process, stable ABI (stabby) native trusted/first-party, max speed

In-process and remote plugins coexist permanently; query/hostbin/group/statictarget stay in-process. Engine core is untouched — every transport registers through the existing factory hooks.

In-process stable-ABI transport (dylib:) — the perf headline

Out-of-process plugins paid ~2×: test //... over the go plugin ran ~7s vs ~3.1s compiled-in. Profiling pinned the gap on the async Mux transport (tokio mpsc + duplex wakeups + frame allocs) — not serialization, and not the ~22k note_dep/result callbacks (they fan out concurrently, hiding latency; an A/B with a spin-mailbox confirmed it).

Fix: a native stable ABI (crates/plugin-stabby, on stabby) so a plugin loads in-process as a cdylib and is called via direct vtable dispatch — no IPC, no hot-path serialization — while staying a separate, ABI-checked, hot-swappable artifact.

  • StableExecutor (result/note_dep/query) crosses callbacks natively; StableProvider/StableManagedDriver cross cold methods as prost pb::Frame bytes; get takes the native executor.
  • crates/plugin-go-cdylib: the go plugin as a loadable cdylib (#[stabby::export]).
  • Host loader: libloading + stabby get_stabbied type-report ABI check (mismatched dylib = hard error).
  • The cdylib owns its own tokio runtime (its statically-linked tokio ≠ the host's), so a driver run shelling out (go list) works.
  • mux: a try_inline fast path serves synchronous note_dep without a task spawn (for the bin: path).

Result: go plugin via dylib: runs ~3.4s (vs the 3.1s compiled-in floor), fresh and cached builds correct. Full ladder: 3.1s compiled-in · 3.4s dylib · 4.7s prost-in-process · 7s out-of-process.

Config

Each provider/driver entry picks a transport (mutually exclusive); absent = in-process built-in:

- name: go
  dylib: { path: .heph3/heph-go-plugin.dylib }   # or url: with {os}/{arch}
- name: other
  bin:  { path: ... | exec: [...] | url: ... }
- name: compute
  wasm: { path: ... | url: ... }

dylib:/wasm: share one path/url artifact source (url downloaded + cached like bin:). gen-example builds + installs the cdylib; example/.hephconfig2 uses dylib:.

Milestones

  • M0 — ABI schema + plugin-abi + plugin-sdk + parity tests
  • M1plugin-remote + transports + host_service + lease
  • M2 — plugin-go out-of-process (note_dep/result split); deliberate-cycle test
  • M3 — shm tier (iceoryx2)
  • M4 — wasm tier + launch/sandbox
  • M5 — in-process stable-ABI (dylib:) transport at native speed; dylib:/wasm:/url config

Notes

  • Result/input artifacts are abstract readers (Content: file/sqlite/in-mem/remote) — cross as opaque handles / eagerly-read bytes, never paths. Only driver run outputs (fresh files) cross as paths.
  • Host-side dep-cycle invariant preserved: every result/note_dep registers the DepDag edge; note_dep is the cache-hit fast path.
  • Perf analysis + won't-fix notes in ai-docs/PERFORMANCE.md (not committed).

🤖 Generated with Claude Code

raphaelvigee and others added 5 commits June 15, 2026 10:50
Foundation for running plugins out-of-process over 3 transports
(proto/shm/wasm), all behind one logical interface.

- proto/plugin/v1/*.proto (pkg heph.plugin.v1): common, targetdef,
  provider, driver, callback, envelope. Source of truth, mirrors the
  in-process hplugin/hmodel types field-for-field.
- crates/plugin-abi: re-exports generated pb, ABI_SEMVER, and a rkyv
  zero-copy mirror of the hot-path messages for the shm fast path, with
  a parity test (proto<->rkyv round-trip) so the encodings can't drift.
- crates/plugin-sdk: Rust guest SDK skeleton (Ctx cancellation,
  HostClient callback surface; authors implement the same
  hplugin::Provider/Driver traits). Transport impls land in M1.
- wit/heph-plugin.wit: contract mirror for the wasm tier (validated M4).
- proto/Cargo.toml.tpl: add pbjson dep (prost-serde emits pbjson code
  for numeric fields).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Free-function conversions (orphan rule forbids From impls) between the
prost wire types and hplugin/hmodel/hcore: Addr, Value, State, TargetAddr,
Sandbox (Tool/Dep/Env), TargetSpec, Matcher. Provider-path scope;
driver-path (TargetDef/raw_def/run) follows in M2. Round-trip unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Working bidirectional, request-id-multiplexed plugin transport, proven
end-to-end in-process over a UDS socketpair.

- plugin-abi (transport feature): length-prefixed Frame framing + a
  symmetric Mux (outbound calls incl. streaming + cancellable; inbound
  requests dispatched to an InboundHandler). Two id spaces never collide
  since each side only matches response-typed frames to its own ids.
- plugin-remote: RemoteProvider impls hplugin::Provider over the mux;
  host callback service serves result/note_dep/query/open_artifact/
  release_lease against the per-request engine executor; lease table
  holds artifact read-guards.
- plugin-sdk: serve() drives an author Provider from inbound frames and
  exposes host callbacks as a ProviderExecutor (MuxExecutor), so plugin
  code calls executor.result() exactly as in-process.
- e2e test: config, streaming list, get + result() callback round-trip,
  cancellation. All green.

Artifact byte streaming (RemoteContent reader/walk) + lease-on-drop are
stubbed for M2; note_dep falls back to result() until the engine
edge-only API lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stubs [M1]

- plugin-remote::spawn_plugin: spawn a plugin child over a UDS socketpair
  passed on inherited fd 3 (dup2 in pre_exec); proto protocol over it.
- plugin-sdk::serve_inherited: guest entry — serve over fd 3, block until
  the host disconnects (Mux::wait_closed, new connection-closed signal).
- crates/plugin-echo: reference plugin binary (heph-plugin-echo) + a real
  subprocess e2e test (config, streaming list, get + result() callback
  through the child). Green.
- plugin-remote shm/wasm: feature-gated transport stubs (M3/M4).

M1 complete: one logical interface, proto transport proven both in-process
and across a real subprocess. shm/wasm scaffolded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…[M2]

Complete the provider result() path: the guest now eagerly pulls each
artifact's bytes via open_artifact (streamed chunks), materializes them as
RemoteContent, and reads/walks them locally (tar walker — the only content
type the cache produces). This is the plugin-go hot read (package.bin).

e2e extended: host hands back a tar artifact, guest get() fetches it
through the transport, walks it, and reads package.bin = "hello". Green.

Lazy/offset chunking + lease-on-Content-drop remain M3 perf work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@raphaelvigee

Copy link
Copy Markdown
Member Author

Progress update

Done, tested, committed:

  • M0 — ABI schema (proto/plugin/v1), plugin-abi (generated pb + rkyv hot-message mirror + parity tests), plugin-sdk skeleton, wit/heph-plugin.wit.
  • M1 — full proto transport: length-prefixed Frame framing + bidirectional id-multiplexed Mux (plugin-abi, transport feature); host RemoteProvider + callback service + lease table + subprocess spawn_plugin (fd-3 inheritance); guest serve/serve_inherited; reference crates/plugin-echo. Proven in-process (4 tests) and over a real subprocess (1 test). shm/wasm are feature-gated stubs.
  • M2 (provider path)pb↔engine conversions (convert feature); artifact byte streaming + tar walk over the result() callback (the plugin-go hot read). e2e reads package.bin through the transport. All green; clippy -D warnings clean.

Remaining (each substantial, some need a decision):

  • RemoteDriver — blocked on the raw_def round-trip: parse() yields a concrete Arc<dyn RawDef> that run() downcasts; over the wire it's opaque JSON. Resolvable via an hplugin::TargetDef deserialize-capable raw_def, or a guest-side parse-time concrete-def cache (one long-lived plugin process per engine makes this safe). run() also needs input-artifact materialization into the shared sandbox dir + output collection.
  • plugin-go migration — wire its provider+drivers to run remote; go-toolchain e2e; deliberate-dep-cycle test (must return CycleError, not deadlock).
  • note_dep edge-only ProviderExecutor API (currently falls back to result()).
  • M3 — real iceoryx2 shm transport + batching + benchmark vs in-process.
  • M4 — wasmtime component transport + launch sandbox modes (exec / exec_in_container / container; landlock/seccomp/.sb).

The hard architectural pieces (the bidirectional transport + the abstract-artifact byte path) are done and tested; the rest is well-scoped follow-on work.

raphaelvigee and others added 24 commits June 15, 2026 15:36
…trip [M2]

A driver's parse() produces a concrete Arc<dyn RawDef> that run()/
apply_transitive() read back via downcast. RawDef was serialize-only
(erased), so a target def shipped to a remote plugin couldn't be turned
back into the driver's concrete config type. This adds the missing
contract:

- hplugin: RawDefBytes — a RawDef that carries the serialized value and
  lazily deserializes + caches it into the requested type. Serializes
  transparently as the wrapped value so re-shipping is lossless.
- hplugin: TargetDef::def_de::<T: DeserializeOwned + Send + Sync>() —
  downcasts the concrete value in-process (zero cost) or materializes it
  from a RawDefBytes. def::<T>() (downcast-only) is unchanged, since
  pluginexec stores a nested TargetDef (not Deserialize) as its raw_def.
- plugin-abi: convert::raw_def_to_blob / raw_def_from_blob bridge to
  pb::RawDefBlob (JSON). Remote-capable driver def types derive
  Serialize+Deserialize and read config via def_de.

Tests cover in-proc downcast, serialized materialization, and lossless
re-serialization. This unblocks RemoteDriver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er serving [M2]

- plugin-abi: driver-path conversions (TargetDef/Input/Output/Path/
  CacheConfig + raw_def via the RawDefBytes contract).
- plugin-remote: RemoteDriver impls hplugin::Driver — config/schema/
  parse/apply_transitive over the mux; run/run_shell deferred (need the
  driver-support ManagedDriver sandbox materialization).
- plugin-sdk: serve generalized to provider and/or driver
  (serve_driver/serve_plugin); guest dispatch for ParseReq/
  ApplyTransitiveReq.
- e2e: a TestDriver's TargetDef (incl. opaque raw_def) round-trips
  through parse + apply_transitive; def_de reconstructs the concrete
  config on both host and guest. Green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…[M2]

run() now works over the wire, plugged in at the driver-support
ManagedDriver layer so the host reuses all existing sandbox setup +
input materialization:

- proto: ManagedRunRequest/Input/Response (paths + metadata only — inputs
  are on the shared filesystem, never streamed) + envelope frames.
- plugin-abi: OutputArtifact <-> OutputArtifactRef conversions.
- plugin-remote: RemoteManagedDriver impls ManagedDriver; the host
  ManagedDriverBridge materializes the sandbox, then forwards the run to
  the out-of-process plugin; outputs come back as paths.
- plugin-sdk: serve_managed_driver + guest reconstruction of the
  ManagedRunRequest (NullContent for on-disk inputs) -> executes the
  plugin's ManagedDriver.
- fix: add ManagedRunResp to mux is_response() (it was misrouted as an
  inbound request, deadlocking the call).
- e2e: guest writes an output file into the host-prepared sandbox; host
  gets the artifact back and the file exists on disk. 6 proto tests +
  subprocess test green; clippy -D warnings clean.

Stdio proxying for the run streams is the remaining run() follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validates the core claim: a RemoteProvider (backed by an out-of-process
plugin served via the SDK over proto) registers on a real Engine through
the normal register_provider factory hook, and an engine get_spec query
routes to the remote plugin and returns its spec. The engine is unaware
the plugin is remote. Green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ction [M2]

A real plugin (plugin-go) exposes a provider plus several named drivers
(golist/embed/testmain) over one process. Support that:

- proto: a `driver` selector field on Parse/ApplyTransitive/ManagedRun
  requests (empty => the sole driver).
- plugin-remote: RemotePlugin owns one connection; .provider(name)/
  .managed_driver(name)/.driver(name) hand out handles that share the mux
  and tag requests with the component name. Remote* gain from_parts.
- plugin-sdk: guest holds a name->ManagedDriver map; serve_components
  registers several; requests route by the selector (single-driver
  fallback when empty).
- e2e: two named managed drivers over one connection, parse routed to the
  correct one. 7 plugin-remote tests green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess [M2]

plugin-go stays Rust; this binary relocates its construction (the `go`
provider + golist/embed/testmain managed drivers) into a process served
over the plugin transport via the SDK, instead of in-process engine
registration. It still shells `go list` internally, unchanged.

- crates/plugin-go: new bin heph-plugin-go using
  serve_components_inherited (provider + 3 named managed drivers over fd 3).
  Builds its own CachedWalker (the process has FS access) and reads root /
  go-bin / walk-db from env.
- plugin-sdk: serve_components_inherited helper (multi-component over fd 3).

Compiles + clippy clean. Bootstrap flip (spawn + register the remote
provider/drivers) and a go-toolchain e2e are the remaining wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire heph-plugin-go into the engine: when HEPH_REMOTE_GO is set, spawn one
heph-plugin-go process (next to the heph binary; root/go-bin passed via
env) and register the `go` provider + go_golist/go_embed/go_testmain
managed drivers as RemotePlugin handles sharing that connection — through
the same factory hooks, with the same config opt-in semantics. Default
stays fully in-process, so production is unchanged.

- plugin-remote: spawn_streams (env + fd-3 socket) + RemotePlugin: Clone.
- root: depend on plugin-remote; bootstrap gates go registration on the
  flag (+ a runtime-present check), falling back to in-process.

Engine core untouched. End-to-end validation needs the go toolchain
(set HEPH_REMOTE_GO and run a go build); the wiring compiles + clippy
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shm tier: an iceoryx2 shared-memory byte-pipe wrapped as
AsyncRead/AsyncWrite, so the entire existing Mux + plugin protocol run
over shared memory unchanged — no protocol reimplementation.

- plugin-abi `shm` feature: two iceoryx2 pub-sub services (one per
  direction) carry the Frame byte stream; a single dedicated io-thread
  owns the !Send ports and bridges to async via channels.
- ShmReadHalf/ShmWriteHalf impl AsyncRead/AsyncWrite, so
  RemotePlugin::connect / serve work over shm with no new API.
- plugin-remote `shm` feature enables it; full plugin-over-shm e2e
  (config + get round-trip through iceoryx2) + a byte-pipe round-trip
  test. Both green; clippy -D warnings clean.

Avoids the per-message syscall (the UDS cost). Follow-up perf-hardening:
rkyv zero-copy payload in the loaned sample, event-driven wakeup
(Listener/WaitSet) instead of the polling io-thread, note_dep batching,
and a benchmark vs the UDS baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…matching)

note_dep: ProviderExecutor gains note_dep (register parent->addr edge,
cycle-check, no execute); default falls back to result, the engine
overrides with a direct track_dep. Host serves the cache-hit path with it
instead of a full result.

Typed transport errors (per review: never match by message):
- proto Error.Kind += CYCLE; GetError.Kind += CYCLE/CANCELLED.
- plugin-abi: WireError{kind,message} (impl Error); the mux raises it on
  an Error frame, carrying the kind.
- host classifies errors by downcasting to the real engine types
  (CycleError/CancelledError) -> typed kind on Error/GetErr/NoteDepResp.
- guest maps the kind back to a typed error (CycleError/CancelledError)
  for plugin code — result/note_dep/get all reconstruct the type.

Test: a host-detected CycleError survives the full round trip
(result callback -> Error{Cycle} -> guest -> GetErr{Cycle} -> host) and
arrives as a typed CycleError at get(). 8 proto tests + engine e2e green;
clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chain

- crates/plugin-exec: new bin heph-plugin-exec serving exec/bash/sh as
  managed drivers over the plugin transport (serve_components_inherited).
  pluginexec stays Rust; the host ManagedDriverBridge materializes the
  sandbox, the guest executes the command and returns artifacts.
- bootstrap: HEPH_REMOTE_EXEC opt-in spawns it + registers exec/bash/sh as
  RemotePlugin factories; default in-process. (Child handle dropped, not
  forgotten — std detaches; plugin self-exits on socket EOF.)
- devenv.nix: wasm component toolchain (wasm32-wasip2 target,
  cargo-component, wasm-tools) to unblock the wasm transport; new
  plugin-* crates added to the fmt set.

Builds + clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cho guest) [M4]

De-risks the wasm tier end-to-end: a cargo-component guest component
(crates/wasm-guests/echo) exporting `greet`, loaded and called through an
in-process wasmtime host (plugin_remote::wasm, feature-gated `wasm`) with
WASI linked. Proves the cargo-component ↔ wasmtime contract before the full
provider/driver WIT + AbiHost host-imports are brought up.

- echo guest: standalone workspace (kept out of host workspace), wasip2
  component built via cargo-component; wit world echo { export greet }.
- host: wasmtime 45 component bindgen + wasmtime-wasi p2; WasiView over
  WasiCtx+ResourceTable; instantiate_and_greet.
- e2e test gated on --features wasm: builds the guest, instantiates, asserts
  greet("heph") == "hello heph".
- devenv.nix: add wasm32-wasip1 target (cargo-component's build+adapt path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mport) [M4]

Extends the echo de-risk slice to exercise BOTH call directions over the
wasm transport, which is the crux of the plugin design: the guest provider
calls back into the engine's executor mid-request.

- echo world gains `import host-lookup: func(key) -> string`; guest `greet`
  invokes it and folds the host's answer into its reply.
- host implements EchoImports for HostState and links it via
  Echo::add_to_linker (HasSelf marker).
- inline WIT in the host bindgen kept in sync with the guest's world.wit.
- e2e asserts the round-trip: greet("heph") == "hello heph (host:heph)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ld [M4]

Brings the wasm tier from de-risk slice to a working provider/driver plugin
over wasmtime, with the host-callback (AbiHost) path wired.

ABI (WIT, crates/plugin-abi/wit/heph-plugin.wit):
- Mirrors the *service* surface — provider (config/get) + driver
  (config/parse/run) exports, host (resolve/note-dep/query) imports. Payloads
  are protobuf-encoded heph.plugin.v1 bytes (single schema source = proto; no
  structural WIT triplication). Typed error via an `error-kind` enum so neither
  side matches on message strings.

Guest SDK (crates/wasm-guests/sdk, wasm-only standalone workspace):
- pb-typed Provider/Driver/Host traits + PluginError + encode/decode helpers.
- Depends on plugin-abi WITHOUT convert/transport (those pull hplugin ->
  hsandboxfuse/hwalk which don't build for wasm); guest works on prost `pb`.

Host (plugin-remote/src/wasm.rs, feature `wasm`):
- WasmPlugin loads a component (async wasmtime 45 + wasmtime-wasi p2); WasmProvider
  / WasmDriver implement the in-process hplugin traits, instantiating a fresh
  store/instance per call so the per-request executor is isolated.
- host imports serve result/note_dep/query against the real ProviderExecutor;
  cycle/cancelled classified by downcast (never message), mapped to the WIT
  error kind.

Hello-world plugin (crates/wasm-guests/helloworld, cargo-component):
- provider.get folds a guest->host `query` callback count into a label; driver
  parse carries the greeting in the opaque raw_def; run returns one inline
  output artifact.

e2e (--features wasm): builds the guest, drives get (callback observed) +
parse (raw_def round-trip via def_de) + run (artifact) — all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TE_* env

Providers/drivers are matched by name: no `bin:` => in-process built-in
(unchanged); a `bin:` entry routes the name to a spawned plugin over the proto
transport. Three launch forms (exactly one):
- bin: { path: <binary> }        spawn directly
- bin: { exec: [argv...] }        argv[0] PATH-resolved + args (e.g. cargo run)
- bin: { url: <url-with-{os}/{arch}> }  download, cache under
  <home>/plugins/<os>-<arch>/, chmod +x, then spawn

bootstrap:
- in-process built-in factories (buildfile/go/go_*/exec/bash/sh) are skipped for
  any name a `bin:` entry overrides.
- register_bin_plugins groups entries by resolved argv so one process serves all
  names sharing a command (the `go` provider + its `go_*` drivers); providers
  register as provider factories, drivers as managed-driver factories.
- spawned plugins get HEPH_PLUGIN_ROOT injected; heph-plugin-go reads it
  (HEPH_PLUGIN_GO_ROOT still overrides).
- removes the HEPH_REMOTE_GO / HEPH_REMOTE_EXEC env opt-ins entirely — the go/exec
  plugins are now launched via a `bin:` entry (cargo run … or a local binary).

`url` downloads use reqwest blocking on a dedicated thread (safe inside the async
runtime). Adds BinConfig/BinSource to config_yaml with parse + validation tests,
plus argv-resolution / os-arch-substitution / spawn-grouping unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Demonstrates the new `bin:` config: the go provider and its go_* drivers point
at the same `cargo run --release -p plugin-go --bin heph-plugin-go` command, so
they share one spawned process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds heph-plugin-go alongside heph in the same cargo invocation across the
existing matrix (linux amd64/arm64, darwin arm64), applies the macOS libiconv
portability rewrite, and uploads it as a release asset
(heph-plugin-go_<os>_<arch>) to hephbuild/heph-artifacts-v1. Lets a workspace
launch the go plugin via `bin: { url: ... }` without building it.

Release download pattern widened heph_* -> heph* to include the plugin (still
excludes the `repo` source artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
download_plugin now substitutes {os} -> linux/darwin and {arch} -> amd64/arm64
(from the rust consts macos / x86_64 / aarch64), matching the CI release asset
names (heph-plugin-go_<os>_<arch>), so `bin: { url }` resolves correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ves vs root

- devenv.nix: add `gen-example` — runs gen-go-large, builds heph-plugin-go
  (release; macOS libiconv rewrite), installs it to example/.heph3/heph-go-plugin.
- example/.hephconfig2: go provider + go_* drivers now use
  `bin: { path: .heph3/heph-go-plugin }` instead of `cargo run`.
- bootstrap: a relative `bin.path` resolves against the workspace root (not the
  process cwd) so it works regardless of where heph is invoked. exec/url unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ckage)

`--bin heph-plugin-go` alone fails ("no bin target in default-run packages")
because the bin lives in the plugin-go package, not the root heph package.
gen-example and the CI build now pass `-p plugin-go` (CI: -p heph --bin heph
-p plugin-go --bin heph-plugin-go).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t scope")

The host keyed the callback scope by the engine request_id, but that id is
shared across a whole request tree. plugingo's import-closure fan-out issues
many concurrent RemoteProvider::get calls under one request_id; each get's
scope teardown removed the shared key, so a sibling's still-in-flight
result()/query() callback failed with "unknown request scope req-0". Concurrent
gets also carry different executors (different cycle-detection parent), so a
shared key was semantically wrong too.

RemoteProvider::get now mints a unique scope id per call (HostInner.fresh_scope_id),
registers the executor under it, and sends it as the wire request_id; the guest
echoes it on every callback, routing to the correct per-call executor.

Regression test: two gets sharing one engine request_id reach the guest as
distinct wire scope ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codegen (gen/) is regenerated per session; gen-example builds heph-plugin-go
which depends on the generated proto bindings, so a fresh checkout failed with
E0432 (hproto_gen::heph::plugin) without a prior `gen`. Run it up front.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…afe)

The go_golist/go_embed/go_testmain run paths read raw_def with def::<T>()
(downcast), which panics out-of-process: the def round-trips parse(plugin)->
host->run(plugin) as serialized RawDefBytes, so the concrete downcast fails
("TargetDef raw_def type mismatch"). Switch to def_de::<T>(), which downcasts
in-process and deserializes the carried blob when remote.

Adds serde::Deserialize to the def types + their enums (GoGolistDef/GoEmbedDef/
GoTestmainDef, EmbedVariant, TestmainMode) and to hplugin Input/InputMode
(GoGolistDef carries Vec<Input>). TargetAddr already (de)serializes as a string,
so no hmodel change is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gin death)

If a plugin process dies mid-request (panic/exit), the host's in-flight
call_cancellable awaited a response that never came — hanging forever. mark_closed
now clears the pending table (dropping senders -> RecvError -> "connection
closed"), call_cancellable also races wait_closed, and it early-returns if the
connection is already closed before issuing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both plugin bins used #[tokio::main(flavor = "current_thread")]. plugingo fans
out `go list` subprocesses and parks workers via block_in_place on every
subprocess chunk read; on a current-thread runtime that path degrades to a 1ms
poll loop ("unit tests only" per proc) and all work serializes onto one thread —
crippling a `//...` walk. Use a tuned multi-thread runtime sized like the engine
(worker_threads = cores, max_blocking_threads = 8N+64) for block_in_place
headroom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
raphaelvigee and others added 7 commits June 16, 2026 13:10
…ck totals)

Logs on engine drop: Mux total frames in/out, and host callback counts
(result/note_dep/query). Diagnostic only — to localize the remote resolve-path
cost (transport round-trips vs fs walk). Revert after measuring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The remote resolve path called executor.result() for every transitive import
edge — 22k+ on a real graph — each re-running the full engine result pipeline
(cache lookup, ResultLock, memoizer, lease + artifact handles) just to register
a dep edge plugingo already had cached. That's the dominant out-of-process cost
(note_dep was 0).

read_golist_package / read_golist_package_addrs now peek the plugin-side cache
(new Memoizer::peek, completed-only, non-inserting): on a hit, register the
parent->addr edge with the cheap edge-only note_dep (a bare DepDag insert) and
return the cached parse, falling back to result() only on a real miss. Cycle
detection is preserved (note_dep registers the same edge and returns CycleError).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…note_dep)

Follow-up to the note_dep split. Previously each caller did note_dep on a cache
hit but a full result() on a miss — and concurrent importers of the same golist
all peek-missed before the first cached it, so the expensive byte-streaming
result() fired thousands of times (result=2523 on a real graph).

Now every caller registers its edge with the cheap note_dep, and the data
result()+parse runs inside the cache `once` — exactly one result() per distinct
golist regardless of how many importers race it. Cycle detection is preserved:
the per-caller note_dep registers the edge synchronously, so a dep cycle is
caught by DepDag at the closing edge rather than deadlocking on the `once`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yscalls)

The remote resolve path is syscall-heavy: one write (len+body) and two reads
(length prefix, then body) per frame, ×140k frames. Two transport-only changes:

- writer_loop drains every queued frame and writes them in a single buffer →
  one write syscall per burst instead of one per frame.
- the read half is wrapped in a 64 KiB BufReader → the length-prefix + body
  reads (and pipelined frames) come from one syscall's worth of data.
- write_frame now emits len+body in a single write_all.

On the plugingo //... resolve: 8.2s -> 6.5s, sys 6.1s -> 4.3s. No protocol or
plugin-logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a working shared-memory transport for out-of-process plugins, but keeps
proto/UDS as the default launch — shm measured slower here (the per-call cost is
async wakeup latency, not syscalls), so it's an opt-in `shm` cargo feature.

shm transport (feature-gated):
- plugin-remote::spawn_shm: host connects an iceoryx2 byte-pipe, spawns the
  plugin with HEPH_PLUGIN_SHM, and uses fd 3 (UDS) for a readiness handshake
  (pub/sub drops pre-subscribe messages) + bidirectional liveness (EOF = peer
  gone). plugin-sdk::serve_components_shm is the guest side.
- shm.rs hardening: adaptive poll (spin while active, back off when idle),
  update_connections so a publisher discovers a late subscriber, 0-length EOF
  sentinel, large no-drop subscriber buffer (4 KiB chunks). Cross-process
  delivery test (re-exec) proves two processes share the services.
- Mux::close for liveness watchers; mux/host temp instrumentation removed.

Default launch stays proto (bootstrap uses spawn_streams). Measured on plugingo
`test //...`: proto ~6.5s, shm ~8.7s, in-process ~2.8s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t native speed

Out-of-process plugins paid ~2x: a `test //...` over the go plugin ran ~7s vs
~3.1s compiled-in. Profiling (ai-docs/PERFORMANCE.md) pinned the cost on the
async Mux transport (tokio mpsc + duplex wakeups, frame allocs), not on
serialization or the ~22k note_dep/result callbacks (which fan out concurrently,
hiding latency).

Add a native stable-ABI transport so a plugin loads in-process as a cdylib and is
called via direct stabby vtable dispatch — no IPC, no serialization on the hot
path — while staying a separately-built, hot-swappable, ABI-checked artifact.

- crates/plugin-stabby: the stable ABI (stabby). StableExecutor (result/note_dep/
  query) crosses callbacks natively; StableProvider/StableManagedDriver cross cold
  methods as prost pb::Frame bytes; get() takes the native executor. host/guest
  adapters bridge to/from the in-process hplugin traits. The cdylib owns its own
  tokio runtime (its statically-linked tokio differs from the host's) so a driver
  run shelling out via proc_exec works.
- crates/plugin-go-cdylib: the go plugin as a loadable cdylib (#[stabby::export]).
- Host loader: libloading + stabby get_stabbied type-report ABI check.
- config: `dylib: { path | url }` and `wasm: { path | url }` (parallel to `bin:`),
  sharing one ArtifactConfig/ArtifactSource + url download path. wasm wires the
  existing wasmtime tier (provider + driver); gated on the `wasm` feature.
- gen-example builds + installs the cdylib; example .hephconfig2 uses `dylib:`.
- mux: try_inline fast path serves synchronous note_dep without a task spawn.

Result: go plugin via the dylib runs ~3.4s (vs 3.1s compiled-in floor), fresh and
cached builds correct. bin: (out-of-process UDS) and wasm: coexist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@raphaelvigee raphaelvigee changed the title feat: external plugins (out-of-process, 3 transports) feat: external + in-process plugins (4 transports incl. native dylib) Jun 16, 2026
raphaelvigee and others added 8 commits June 17, 2026 00:01
The go plugin now ships only as a separate artifact — loaded in-process via
`dylib:` (native speed) or spawned via `bin:` — so heph no longer links plugin-go
into the binary. Removes the in-process `go`/`go_*` factory registrations, the
root `hplugin-go` dependency, and the `heph::plugingo` re-export. A config entry
named `go` without a transport now has no factory (by design).

plugingo-e2e constructs the provider directly, so it depends on `plugin-go`
itself instead of the (removed) `heph::plugingo` re-export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed errors

CI lint only checked default-feature code, so the shm/wasm feature-gated
transports were never linted, and fmt skipped the new plugin-stabby /
plugin-go-cdylib crates.

- devenv `lint`: add a `clippy --all-targets --all-features` pass (covers
  feature-gated code and both arms of `#[cfg(feature)]`); add plugin-stabby and
  plugin-go-cdylib to the fmt-checked qualityCrates.
- Fix the errors this surfaces:
  - non-binding `let _` on a must_use future result in the shm liveness watchers
    (plugin-remote spawn_shm, plugin-sdk serve_components_shm).
  - fmt-align e2e/plugin-echo to the current toolchain's rustfmt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Default features now include `shm` and `wasm`, so the shipped heph binary
  supports every plugin transport (bin: UDS/shm, dylib:, wasm:) out of the box.
- `tst` runs `cargo test --all --all-features`, so CI exercises the feature-gated
  transports + their e2e tests (shm cross-process, wasm component) instead of only
  default-feature code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo test --all --all-features` links wasmtime + the wasm toolchain + iceoryx2
into the integration-test binaries; full debuginfo made the linker OOM (SIGBUS,
ld exit 135) on CI runners. line-tables-only keeps backtraces with line numbers
while cutting the link size enough to fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo test --all --all-features` aborted the macOS Test job: --all-features turns
on `fuse-sandbox` workspace-wide, which hard-links libfuse into test binaries
(e.g. plugin-abi's) that lack the `-weak-lfuse` build.rs, so they fail to launch on
the macFUSE-less runner (dyld: Library not loaded: libfuse.2.dylib).

Replace with a default `cargo test --all` (heph default now pulls shm+wasm) plus
targeted per-crate passes for the feature-gated transports — none enable
fuse-sandbox, so no spurious libfuse link:
  - plugin-abi  --features shm           (shm cross-process)
  - plugin-remote --features shm,wasm    (proto/shm/wasm e2e)
  - plugin-stabby --features host,guest  (stabby roundtrip)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo test --all` unifies the default `fuse-sandbox` feature across the
workspace, so the new crates' test binaries transitively link `fuser` -> libfuse
through their hplugin/sandboxfuse deps. Without `-weak-lfuse` they hard-link
/usr/local/lib/libfuse.2.dylib and dyld aborts at launch on the macFUSE-less
macOS runner (plugin-abi's test binary, SIGABRT). Add the standard `-weak-lfuse`
build.rs (matching plugin-exec/plugingo-e2e/heph) to plugin-abi, plugin-sdk,
plugin-remote, plugin-stabby. Verified: all test binaries now use
LC_LOAD_WEAK_DYLIB for libfuse (none hard-link it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continuing the macOS fuse fix: with plugin-abi fixed, the next binary to abort on
the macFUSE-less runner was `heph-plugin-echo` (plugin-echo's bin, launched by its
subprocess test). plugin-echo and plugin-go-cdylib both transitively link fuser
but lacked the `-weak-lfuse` build.rs. Add it. Critically, the cdylib must weak-
link too, or `dlopen` of the shipped plugin fails on a Mac without macFUSE.

Verified: no bin/test/cdylib artifact hard-links libfuse (all LC_LOAD_WEAK_DYLIB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shm cross-process test flaked on linux: `open_or_create` races another
process opening/creating the same service and returns the transient
`PublishSubscribeOpenOrCreateError::SystemInFlux`. Retry the service setup briefly
(up to ~0.5s) instead of failing — hardens the shm byte-pipe under concurrent
plugin starts too, not just the test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@raphaelvigee raphaelvigee marked this pull request as draft June 18, 2026 10:34
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