kunobi-daemon
Coordinate local daemon upgrades without reporting success from a retiring process or deleting the replacement's lock.
This crate provides:
ProcessLock: exclusive ownership through a persistent OS file lock.Lifecycle: close admission on every session and wait for active request guards.ensure_current: serialize replacements, recheck after acquiring the upgrade lock, and return only a live response that satisfies the caller's version policy.publish_record: publish complete discovery records with an atomic rename.transport: the Kunobi relay's bounded byte pumps, replaceable writer and pause boundaries for newline-framed messages.
The crate is not yet published on crates.io. Consumer migration is being validated separately.
Blocking relays can use default-features = false to obtain process locks and
transport and publication primitives with no runtime dependencies. The default async feature
adds Tokio-based draining and upgrade coordination.
Request draining
use ;
use ;
use Instant;
async
A drain timeout reports how many guards remain. It does not cancel work or reopen admission. A broker can continue waiting for a mutating request; a cache can abort an upload after persisting enough state to retry it.
Upgrade contract
Pass ensure_current a persistent upgrade-lock path, one deadline, a live health
probe, a version predicate, and a replacement callback. The probe returns
Ok(None) while no daemon is available. Probe errors propagate.
The fast path is one probe. When replacement is needed, the function acquires the upgrade lock and probes again: a concurrent client may already have finished the upgrade. After replacement, it waits for a fresh accepted response. An old response, a successful spawn, or an existing socket is insufficient.
Health should read process identity from memory, independently of storage scans and maintenance locks. Callbacks must yield and tolerate cancellation. The shared deadline bounds cooperative async work; it cannot interrupt blocking code or undo a service-manager command.
Ownership and integration
Use different lock paths for daemon ownership and upgrade serialization. Keep the daemon's ownership guard through endpoint cleanup. Every process that starts or recovers that instance must follow the same locking protocol. Never unlink or replace the lock file. Paths belong in a private application-owned directory.
Service-manager integration stays with the caller: a launchd/systemd-managed daemon must be replaced through that manager. Transport framing, protocol compatibility, process identity verification, durable queues, and rollback to a previous binary also remain caller responsibilities.
Kache and Kunobi share these lifecycle requirements but have different upgrade policies. Kache compares build epochs and has persistent upload jobs; the broker checks protocol compatibility and carries calls that may modify external state. The API accepts the version policy and reports unfinished work so both can keep their existing semantics.
Development
Rust 1.89 or newer. Run cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test. CI checks Linux, macOS, Windows, and the minimum Rust
version. Tests cover concurrent upgraders, stale replies, cancellation, multiple
drain observers, and file ownership across real child processes.
The process handoff suite also checks that a relay keeps its
client session through replacement, preserves late replies and never replays
an ambiguously accepted request. Run it with cargo test --test process_handoff.
Apache-2.0. See LICENSE.
Binary lifecycle protocol
The optional wire and wire-async features provide bounded
Protobuf messages and version/capability negotiation on a separate endpoint.
Legacy clients retain their listener and message format. Both adapters can use
the same lifecycle and handoff logic. The protocol is experimental until its
first consumer release; applications still own peer authorization and deadlines.
Capacity and local observations
admission::Admission gives handshake, application and control connections
independent budgets. Defaults are 32 pending handshakes, 256 application sessions
and 32 control sessions; consumers can configure each with Limits. Authenticate
and classify connections before assigning an application or control permit.
Never dispatch application work using a control permit. Permits release on drop.
These defaults are capacity bounds, not measured throughput guarantees.
observation::Observations holds local counters and a bounded queue of typed
events. ObservedIo counts blocking I/O; AsyncObservedIo does the same with
wire-async. Their snapshots remain readable while a write is pending. They
report bytes accepted by the transport, pending I/O, continuous busy time, time
since progress, errors and connection count. An async Pending poll remains
recorded until completion or transport drop; cancelling its caller does not prove
that the underlying I/O stopped. Counters are approximate concurrent samples.
Lifecycle::snapshot reports active request guards and drain state.
Lifecycle::observed also records drain transitions. Consumers record their own
rejection and handoff events with Observations::record, then decide how to log,
aggregate or export them. take_events consumes the shared queue. Producers drop
events instead of waiting on a full or busy queue; lost_events makes that loss
visible. No exporter, background task, user callback, payload or token is stored.
Use a separate observation instance when per-connection progress is needed.
Transport adapters own authentication and setup deadlines for both reads and writes. Clear setup deadlines before ordinary application traffic. Long jobs and slow readers are application policy; observations never impose a job timeout or cancel work.