The engine — a shared on-demand instance per bundle¶
How Outcrop's engine runs, who starts it, how clients find it, and when it goes away. The pattern is a shared on-demand engine instance per bundle — prior art: SQL Server LocalDB's "automatic instance", the Gradle daemon, and language servers. Not a daemon (nothing to install or administer), and deliberately not the sidecar pattern (a sidecar serves one client and shares its lifecycle; this engine is shared and owns its own).
User vocabulary stays what it always was: the engine. It starts when you
need it, is shared when that helps, goes away when you're done, and
outcrop engine status shows it to you.
Why¶
Every client either cold-boots the whole engine per invocation (CLI, MCP, LSP) or depends on a manually managed TCP server (canvas, port 7411) — the latter produced a real field failure: a zombie server squatting the port. A prosumer should never administer a database process. The engine must boot seamlessly with any client, be shared where that buys coherence, and remove the zombie class by construction.
Three-layer coordination¶
Trust kernel-attached properties, not file contents.
- Liveness/mutex:
flockon.outcrop/engine.lock. The engine holds an exclusive advisory lock for its whole life. The kernel releases it atomically with process death — crash, SIGKILL, anything. Lock held ⇒ engine alive; lock acquirable ⇒ engine dead. No pid checks, no heartbeats, no stale states. This is the only authority. - Discovery:
.outcrop/engine.json, hints only. Carries the socket path (authoritative — sockets may relocate, see below), plus pid and version for display/diagnostics. Never trusted for liveness; garbage or torn contents are harmless because of layer 3. - Truth test: connect and say Hello. A client adopts an engine only
after a successful
HelloRPC over the socket returning engine version and canonical bundle root. This is the only check that verifies what clients actually need: a responsive, compatible engine on this bundle.
Transport¶
- Unix domain socket, default
.outcrop/engine.sock, owner-only permissions. When the UDS path-length limit (~104 bytes) bites, the socket relocates to$TMPDIR/outcrop-<root-hash>.sockand theengine.jsonsocketfield is how clients find it. - One socket serves both planes:
outcrop.v1(data, the frozen contract) andoutcrop.engine.v1(control:Hello,Attach,Shutdown,Status) — so the Hello truth test exercises the same connection data will use. - The control plane lives in
proto/engine.proto— a separate file soproto/outcrop.protostays frozen. - TCP (
outcrop serve --grpc) remains an explicit opt-in and is the local Windows path; the engine module is unix-only for now (revisit trigger: Windows named-pipe transport). It is not a remote surface: no engine ever serves another machine's client (decisions/clients-host-their-own-engine) — cross-device consistency is git sync against a remote, whose TLS and auth do the transport-security job a served engine would have needed.
Lifetime¶
- Lease = an open
Attachstream. Every persistent client (canvas, MCP session) opens a long-lived server-stream after adopting. The kernel closes the stream when the client dies — however it dies — so the engine's session table is always true. One-shot CLI calls never lease. - Linger: the engine exits after
max(last lease dropped, last RPC served) + linger(default 45s,OUTCROP_ENGINE_LINGER_MSto tune; 0 = exit immediately when idle). An absolute idle cap bounds the degenerate cases. Rapid relaunches and CLI bursts adopt a warm engine instead of rebuilding indexes. - Other exits: watcher root gone (bundle moved/unmounted) → exit now;
ShutdownRPC (outcrop engine stop) → graceful exit.stopfalls back to SIGTERM on the hinted pid if the socket is unresponsive, and verifies death by acquiring the flock — unforgeable proof. - An idle engine holds nothing: no git index lock, no open
transactions (staged state is durable on disk by design), no unswept
durable state. Merge proposals are durable under
.outcrop/proposals/(see below), so linger-exit loses only warm caches.
The adoption dance (cross-language contract)¶
Implemented by Rust (outcrop-server::engine::rendezvous) and re-implemented
thinly by Swift (EngineLauncher). Keep both exactly to these steps:
- Read
.outcrop/engine.json. Absent or unparseable → no hint; never an error. - Connect (hinted socket path, else the default) with a ~500 ms deadline;
call
Hello(client_kind, client_name, protocol_version). - Adopt iff Hello succeeds and its
bundle_rootcanonical-equals ours and versions are compatible. Anything else = adoption failed (no distinction needed). - Spawn-permitted callers only: spawn
outcrop engine run --root <root>detached, then re-poll steps 2–3 with backoff up to ~5 s. There is no client-side lock — race safety is the engine's flock. Losing spawners exit 0 after confirming another engine answers Hello; every poller converges on the winner. - Version mismatch → do not spawn (the socket is held by a live,
wrong-version engine). Fall back to solo mode with a warning that names
outcrop engine stop. - Persistent clients open
Attachimmediately (the lease).
Who adopts what¶
| Client | Policy | Why |
|---|---|---|
| Canvas | spawn-or-adopt | It has no other way to exist; holds a lease |
| MCP | adopt-or-spawn | Shared state unlocks cross-surface propose/resolve; holds a lease |
| CLI | adopt-if-present only | Speed when warm, hermetic purity when not; never spawns; --no-engine / OUTCROP_NO_ENGINE forces solo |
| LSP | standalone (non-goal) | Tight latency budget; sharing a runtime with heavy queries risks editor stutter; its working set is small. Revisit when graph features land in editors |
Degradation: solo mode¶
If flock errors (network filesystems — Dropbox, iCloud Drive, NFS —
where advisory locks are unreliable), Outcrop degrades to solo mode:
every client runs its own in-process engine, exactly today's behavior. This
is safe, not merely tolerable, because the files are the database — commits
serialize through git, writes are atomic, external edits are first-class.
Sharing is a performance state, never a correctness state. The same
fallback applies to any adoption failure in the CLI (silently) and to
version mismatch everywhere (with a warning).
The engine as a library (in-client engines)¶
Decided: every client hosts its own engine
(bundle/decisions/clients-host-their-own-engine). The shared on-demand
instance above is the desktop realization of that decision. iOS and
Android link the engine in-process — iOS cannot spawn processes, so
the rendezvous/spawn machinery cannot exist there at all. That obliges the
engine module to ship two facades over one core:
- Binary —
outcrop engine run, everything above: flock, socket, linger, adoption. - Library — a lib target exposing both planes (
outcrop.v1+outcrop.engine.v1) over an in-process channel, so a mobile client's generated gRPC code is identical to desktop's; only the channel construction differs. Nothing in the engine's API may assume flock/spawn/linger — those are properties of the shared deployment, not of the engine.
In-process hosting is solo mode by design rather than by degradation:
one client, one engine, and the same correctness story (the files are the
database; commits serialize through git). The feasibility facts are
verified: the transaction layer drives git through libgit2 (git2 crate,
no subprocess anywhere), and DataFusion + tantivy are pure Rust — the
stack cross-compiles. Unmeasured until the first device build: stripped
in-process binary size.
Durable merge proposals¶
Proposals move from server memory to .outcrop/proposals/<id>.json
(atomic temp→fsync→rename writes), so they survive engine restarts and can
be resolved by a different client than the one that proposed — the
human-reviews-agent-work loop. They persist as JSON machinery under
.outcrop/, not as records: a proposal carries machine merge plans and git
object ids, which are engine state, not knowledge. (This is the deliberate
answer to the "why isn't this a record?" challenge in the ethos: it fails
the test — it is machinery.) Stale proposals (referencing commits that no
longer resolve) report cleanly: "re-propose".
Observability¶
outcrop engine status shows: engine version, bundle root, uptime, total
RPCs served, and each attached client (kind, name, pid, attached-at). When
something feels stuck the diagnostic is one command, not lsof
archaeology. outcrop engine start|stop|run complete the verb set.
Known limitations & revisit triggers¶
- Windows: no UDS engine; TCP serve remains the path. Trigger: Windows client gets a UI.
- macOS App Sandbox: sandboxed builds can't reach arbitrary UDS paths; dev builds are unsandboxed. Trigger: App Store distribution.
- LSP adoption: deliberately out. Trigger: editor features that need the shared link graph; adopt read-only if so.
- Fairness: per-connection limits are deferred until agent load is real. Trigger: first observed interactive-latency degradation under agent traffic.
- Library facade: doesn't exist yet — the engine module is socket-only today, and the in-process channel plus the no-spawn/flock/linger API boundary are unbuilt. Trigger: the first iOS/Android client build (cross-platform client MVP).