fix(runtime): refuse to boot a second khived instance - #2281
Conversation
A live PID plus an accepting Unix socket is not proof of khived: any unrelated process that happens to have bound the same path also answers `connect()`. Treating an accepting socket as an incumbent refuses a legitimate boot; treating it as stale removes a socket a real instance is serving. Startup now probes the listener before deciding. It sends a bounded `probe_only` frame carrying this process's own `config_id` and requires the exact probe-branch response shape back — `ok=true`, `result=None`, `error=None`, `metrics=None`, `request_id=None`, no mismatch flags, matching protocol version, and a matching `served_config_id` shape. A peer that answers that way is a real khived instance and must not be treated as stale; one that connects but never answers, times out, or replies with non-protocol bytes falls through to the existing stale-socket recovery path. The response shape is checked in full rather than by `ok=true` alone. The metrics branch is otherwise identical to the probe-ack branch — same `ok`, same absent result and error, same protocol version, same `served_config_id` shape — and is distinguished only by carrying `metrics: Some(...)`. Accepting a metrics reply as an identity ack would let any responder that echoes those fields impersonate an incumbent, so `metrics` and `request_id` must both be absent. A frame that parses but reports `version_mismatch` or `config_mismatch` still proves the peer speaks this protocol, so it counts as an incumbent. The probe is bounded at 500ms: short enough that a hung or foreign listener cannot stall startup, long enough for a live instance under normal load to answer. Adds an integration test asserting a second boot refuses loudly while the first is live. It needs the `fault-injection` feature, which tests under `tests/` link as an external crate, hence the dev self-dependency. Co-Authored-By: leo <noreply@khive.ai>
|
Moving this back to draft: the incumbent-detection path can delete the
ADR-049 Amendment 6 allows cleanup-and-bind only in states 10 and 11; states Two smaller items in the same area:
The test also does not fail if the new predicate alone is reverted: with "any The |
ohdearquant
left a comment
There was a problem hiding this comment.
Automated review. Posted by this repository's automated pull-request review pipeline; this is not a human read and does not gate the merge by itself.
Verdict on head 233b8d5: REQUEST-CHANGES, 1 blocking finding. Finding details are delivered to the review's recipients rather than posted here. Do not merge this head while blocking findings are outstanding; a pipeline comment on a newer head supersedes this one.
Both sides changed what a live PID file owner means. The branch refuses loudly with the pid named; main declines to remove an ambiguous live owner because a draining incumbent closes its listener before it releases writers. Neither alone is right, so the check now has three outcomes: a live process that answers the khived protocol, a live process that answers nothing, and stale. The first two both refuse to start and differ only in the message; only stale proceeds.
|
Rebased onto current This branch refuses loudly when another instance owns the store: non-zero exit
Both are right about their own case. The check now has three outcomes:
The first two both refuse to start and differ only in the message; the second Verified on the pinned 1.95.0 toolchain: |
The new enum landed between the cfg attribute and the function, so the attribute gated the enum and the function lost it, and the Windows compile reached callees that do not exist there. Also restore the self-dependency row the lockfile needs under --locked.
What
Startup's duplicate-daemon check treated any accepting Unix socket as an incumbent. A live
PID plus an accepting socket is not proof of khived — any unrelated process that has bound
the same path also answers
connect(). Treating that as an incumbent refuses a legitimateboot; treating it as stale removes a socket a real instance is serving.
Startup now probes the listener before deciding: a bounded
probe_onlyframe carrying thisprocess's own
config_id, requiring the exact probe-branch response shape back.Why the whole shape is checked, not
ok=trueThe metrics branch is otherwise identical to the probe-ack branch — same
ok, sameabsent
resultanderror, same protocol version, sameserved_config_idshape — and isdistinguished only by carrying
metrics: Some(...). Accepting a metrics reply as anidentity ack would let any responder echoing those fields impersonate an incumbent. So the
check requires
ok=true,result=None,error=None,metrics=None,request_id=None(this probe frame never sets one), no mismatch flags, matching protocol version, and a
matching
served_config_idshape.A frame that parses but reports
version_mismatchorconfig_mismatchstill proves thepeer speaks this protocol, so it counts as an incumbent rather than falling through to
stale-socket recovery. A connect that succeeds but never answers, times out, or replies
with non-protocol bytes is not khived and takes the recovery path as before.
The probe is bounded at 500ms — short enough that a hung or foreign listener cannot stall
startup, long enough for a live instance under normal load to answer.
Relationship to #2230
This is the daemon half of #2230, cut onto a fresh branch from current
main. #2230 mixedthis with an unrelated stdio-bridge idle-timeout change across nine commits and went
conflicted; the two halves are independent (
khive-runtimesits belowkhive-mcpin thedependency order, so nothing here depends on the bridge work). The bridge half is not
included and does not block this.
Worth noting for anyone reconstructing the split: the commit subjects do not partition the
work.
fix(mcp): scope the idle timeout to genuinely idle sessions and probe daemon identityis labelled as an mcp-only change but contributes 113 lines tocrates/khive-runtime/src/daemon.rs— in fact the probe function above. Splitting on thesubject lines would have dropped it.
Verification
Run against this branch, from a clean target directory:
cargo fmt --all -- --check— cleancargo clippy -p khive-runtime --all-targets -- -D warnings— cleancargo test -p khive-runtime— 1535 passed, 0 failed, 8 ignored across all targets,including the new
second_daemon_boot_refuses_loudly_while_first_is_liveThe new integration test needs the
fault-injectionfeature: tests undertests/link thelib as an external crate, so items gated
any(test, feature = "fault-injection")areotherwise unreachable. Hence the dev self-dependency in
Cargo.toml.One doc comment cites
crates/khive-mcp/src/daemon.rs::probe_daemon_identity; that functionis pre-existing on
main, so the reference resolves without the bridge half.