runtime: supervised, killable dynamic grammar and extractor host #79
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
Depends on
Reference
h-dv/code-index#79
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Child of #75. Depends on #76.
Decision
Dynamic grammar loading is no longer a deferred experiment. It is the runtime foundation of the plugin architecture.
The verified tree-sitter WASM work remains valuable, but its unsafe lifecycle properties change the deployment design: untrusted grammar and extractor execution runs in a supervised helper process, not inside the daemon or rayon parse pool.
Why a process boundary is mandatory
Verified constraints in tree-sitter 0.22.6:
A long-lived daemon cannot accept “one malformed file or grammar permanently consumes a worker” as degradation. OS-process termination is the initial correctness boundary even if future tree-sitter/wasmtime APIs make in-process interruption possible.
Runtime topology
Ship a dedicated code-index-plugin-host executable with the same release.
The daemon/indexer owns a bounded supervisor:
A worker never loads two package digests into one store. Package upgrade starts a new worker; it does not mutate a running worker.
The parent sends file bytes and a project-relative logical path. The child does not open project paths. Standard input/output framing must not share the MCP server protocol or logs. Stderr is bounded diagnostic output and cannot block the child.
Process containment
Cross-platform requirements:
WASM still supplies deterministic guest isolation inside the child. The process boundary supplies killability and package-to-package isolation.
Security claims must be stated per platform. If filesystem/network denial cannot be strongly enforced on a supported platform, the payload and documentation say so; do not call a sanitized environment a sandbox.
Grammar loading
The host validates before first parse:
Legacy dylink vs dylink.0, ABI 12/15 behavior and scanner-built artifacts each receive fixtures with stable rejection codes.
Package tooling must provide a documented reproducible grammar build path. Requiring an undocumented Docker/emscripten ritual is not an ecosystem. The project may ship a builder image, but package format and runtime cannot depend on Docker being installed on the user machine.
Extractor execution
The host supports the #76 tiers:
Grammar and extractor may execute in the same per-package worker because the process is already the package trust boundary. They do not share state with another package.
The exact tree representation supplied to extractor.wasm must be benchmarked before ABI freeze:
Raw C pointers, Rust layout and tree-sitter internal struct layout are forbidden ABI.
Region extraction for embedded languages is orchestrated by the parent/supervisor. A wrapper package returns validated regions and source maps; the supervisor dispatches region bytes to the selected installed host-language component and remaps validated spans. Recursive embedding has a strict depth and total-byte budget.
Scheduling and backpressure
The parent distinguishes timeout, worker crash, ABI rejection, resource refusal, invalid facts and user cancellation. Each has a stable reason code used by #80.
Failure policy
Per-file failure:
Per-package failure:
A one-off malformed project file must not permanently quarantine a sound grammar unless it repeatedly crashes the worker; parse errors returned normally are not crashes.
Trust and installation
A repository may request a digest but cannot install or execute it. Installation and capability approval occur in user-controlled state.
Minimum flow:
No automatic remote download during indexing. Registry/network distribution can be layered above an explicit install command; the indexer operates on local immutable packages.
Performance contract
The earlier measurements remain baselines, not acceptance:
Measure:
Hot built-ins may remain native, but shadow mode must prove the package path produces equivalent facts. Performance fast paths cannot use different extraction semantics.
Tests
Hostile components:
Lifecycle:
ABI:
Acceptance
investigate: WASM grammar loading — verified working, deliberately deferred, five blockers namedto runtime: supervised, killable dynamic grammar and extractor hostAll seven acceptance criteria met — closing
Shipped across v0.24.0–v0.25.0.
code-index-plugin-hostis one of the four released binaries..cipby pinned digest and indexes.xamlwith symbols; no code-index rebuildplugin-supervisorowns the splitcrates/plugin-supervisor/src/contain.rs+crates/plugin-host/tests/containment.rscrates/plugin-host/tests/mixed_load_bench.rspackages_rejected_by_failed_generations/runtime_quarantined_packages, which are deliberately separate fieldsCriterion 5 is stronger than the issue asked for
The issue insisted on "Windows and musl runtime smoke tests, not link-only gates". The release pipeline does that and then refuses to ship a leg that fails it:
An unproved musl archive is withheld and the release notes say so, rather than shipping and hoping. Windows additionally gates the whole release via
windows-gate.The isolation decision held up under adversarial use
The epic's premise — that in-process tree-sitter WASM cannot be the security boundary because an infinite lexer is not interruptible through its public API — was correct, and OS-process termination remains the boundary. A separate finding this week (three binaries linking a wasm compiler they never used) was a PACKAGING defect, not an isolation one, and is fixed:
code-index,code-index-daemonandcode-index-mcpno longer carry cranelift at all.Correction: this did NOT close, and the tracker was right to refuse
The comment above says "closing". It did not. Forgejo rejected it:
#79 depends on #76, and #76 is genuinely not done. Its acceptance criterion 6:
#86 — "plugin ABI: three gaps that block migrating any language other than Ruby or PHP" — and #87 — "a package cannot replace a builtin, and a guest cannot name a node kind" — are that criterion failing, enumerated. One markup package exists; all seven real languages are still compiled into the binary.
So the seven criteria in the comment above stand as an accurate account of #79's own scope, which is complete. The issue nonetheless stays open because the epic deliberately made it depend on an ABI that has not yet proved genericity. That dependency is doing its job: it stopped a "runtime plugin host, done" claim resting on a host that has only ever run markup.
The graph, for the record — everything funnels to #76, which has no dependencies:
Nothing here should be force-closed by cutting the dependency edges. The honest state is: infrastructure delivered and shipped in v0.24.1/v0.25.0; genericity unproven; #84/#86/#87 are the work that unblocks the chain.
#81 was the one issue in this set that could close on its own, and it has.
#![cfg(unix)]file-wide, so its properties are ungraded on the Windows we ship #117All seven criteria met. Closing on
7b3fc7c, CI runs 587 and 586 green.Criteria 1, 2, 3, 6 and 7 were already met (unknown grammar loads and parses; a hostile infinite lexer is terminated inside the deadline and the daemon answers afterwards; packages never share a worker or store; #116 gave the mixed-load ceilings real teeth; #115 made host failure a failed pending generation). The two that remained:
Criterion 5 — Windows and musl execute a real grammar plus trap/timeout smoke
musl was already done, and my own framing of it was stale.
release.ymlno longer runs musl undercontinue-on-error/optional: true—build-muslis a separate job carryingplugin_smoke: static, runningrelease_smokebefore packaging, so a binary that fails never becomes an archive. Archives have shipped since v0.25.0.Executed independently twice, including inside a real musl userland (
alpine:3.24.1), against a binary built exactly asrelease.ymlbuilds it:The risk this issue flagged — musl plus wasmtime, signal-based trap handling and small default thread stacks — does not materialise.
fileconfirmsstatic-pie linked, so no glibc loader is involved either way.Windows was a missing step, not a missing machine.
ci-windows.ymlrancargo test, which compiles examples and never runs them — so no grammar had ever been loaded, no lexer killed and no guest trapped on a real Windows host. One step added, and it passed on its first-ever execution in run 586.What it proves, stated precisely: the
x86_64-pc-windows-msvcdebug binary built on the runner. It proves nothing about the shipped archive, whichrelease.ymlcross-links windows-gnu; that concession is unchanged.support::artifact::linkagereads the PE import table and fails the step if it is ever pointed at a gnu-linked binary — verified against a real MinGW-linked Rust PE. Debug is deliberate: the one Windows defect this exists to catch isWORKER_SERVE_STACK_BYTES, and debug frames reach stack exhaustion sooner, so it is the stricter test. No new runner provisioning, and the file's "minimal / self-healing" constraint is respected.Criterion 4 — no filesystem or network access "under every platform claim we publish"
As literally written this is false and cannot be made true: there is no filesystem confinement on any platform, and network denial is seccomp-only on Linux x86_64/aarch64. #114 corrected the README, which had claimed "a sandboxed worker with no filesystem and no network."
So the criterion is satisfiable only in its honest reading — and closing it turned on a hard finding:
enforcedis therefore unpublishable by any parent, and printing it off acfgwould have been #114's overclaim one level down. What ships instead is complete in the direction it can be:project_overview→plugin_activation.worker_containment={filesystem, network, target, semantics}, absent when no plugin host runs, read from the livePackageHost's policy rather thanHostPolicy::default().plugin status→containment+containment_scope.network∈requested_unverified|unsupported|not_requested;filesystemis alwaysunconfined.It is not graded by restating its own
cfg: the test spawns a real worker and requires the constant to admit what the kernel actually did. Budget measured, not argued — three drafts trimmed 431 → 281 → 245 tokens, andratchet.jsonwas not raised.Three now-false documents corrected in the same change: the README ("no operator-facing surface exposes it today"),
80-threat-model.md§6 ("the worker says so, in the payload"), and80-abi-support-policy.md's smoke row ("NOT MET for two of four").Two mutations survived, and both produced better tests
PT_INTERP3 → 4 survived becausePT_NOTEis 4; re-run as 3 → 99 it goes red, and it produced the ELF-pair test.for_platform, which makes the ordering gradeable from a single machine.A third mutation found a real defect in the lane's own PE parser: an unresolvable import-name RVA was silently skipped, which would have flipped
gnu→msvc.Both survivals are written into the files rather than quietly replaced.
GUEST_ABI_MAJORis bracketed by no manifest field, andplugin pack --check-reproducibledoes not exist #153