feat: external + in-process plugins (4 transports incl. native dylib)#86
Draft
raphaelvigee wants to merge 44 commits into
Draft
feat: external + in-process plugins (4 transports incl. native dylib)#86raphaelvigee wants to merge 44 commits into
raphaelvigee wants to merge 44 commits into
Conversation
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>
Member
Author
Progress updateDone, tested, committed:
Remaining (each substantial, some need a decision):
The hard architectural pieces (the bidirectional transport + the abstract-artifact byte path) are done and tested; the rest is well-scoped follow-on work. |
…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>
…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>
…dge via note_dep)" This reverts commit ab676c4.
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
bin:)wasm:)dylib:)In-process and remote plugins coexist permanently;
query/hostbin/group/statictargetstay in-process. Engine core is untouched — every transport registers through the existing factory hooks.In-process stable-ABI transport (
dylib:) — the perf headlineOut-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 ~22knote_dep/resultcallbacks (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/StableManagedDrivercross cold methods as prostpb::Framebytes;gettakes the native executor.crates/plugin-go-cdylib: the go plugin as a loadable cdylib (#[stabby::export]).libloading+ stabbyget_stabbiedtype-report ABI check (mismatched dylib = hard error).runshelling out (go list) works.mux: atry_inlinefast path serves synchronousnote_depwithout a task spawn (for thebin: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:
dylib:/wasm:share one path/url artifact source (url downloaded + cached likebin:).gen-examplebuilds + installs the cdylib;example/.hephconfig2usesdylib:.Milestones
plugin-abi+plugin-sdk+ parity testsplugin-remote+ transports + host_service + leasedylib:) transport at native speed;dylib:/wasm:/url configNotes
Content: file/sqlite/in-mem/remote) — cross as opaque handles / eagerly-read bytes, never paths. Only driverrunoutputs (fresh files) cross as paths.result/note_depregisters the DepDag edge;note_depis the cache-hit fast path.ai-docs/PERFORMANCE.md(not committed).🤖 Generated with Claude Code