Compare commits

..
6 Commits
Author SHA1 Message Date
logaritmiskandClaude Opus 5 c3fe2e414b chore(deps): update the lockfile
Clears RUSTSEC-2026-0190 (anyhow 1.0.102, unsoundness in
Error::downcast_mut, patched >= 1.0.103) by taking anyhow 1.0.104.
`cargo audit` is clean afterwards.

Also drops the WASM component toolchain from the tree — wit-bindgen,
wit-component, wit-parser, wasm-encoder, wasmparser, wasm-metadata,
wasip2/wasip3 — which entered via tempfile -> getrandom 0.4.2 and go
away with getrandom 0.4.3. 153 -> 129 crate dependencies.

Notable: tokio 1.52.3 -> 1.53.1. The stdin keep-open work depends on
Child::wait() dropping its own stdin handle; the RealChild test covers
that and still passes.

etcetera 0.10 -> 0.11 and nix 0.30 -> 0.31 are deliberately NOT taken
here: both need manifest edits, and both are load-bearing (XDG path
resolution, setpgid and signals).

143 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 10:37:21 +02:00
logaritmiskandClaude Opus 5 ace5bd6e87 Merge feat/stdin-mode: keep a supervised child's stdin open
Adds a per-server `stdin` mode (inherit / null / keep-open) so stdio-first
MCP servers that also speak HTTP can be supervised without exiting on EOF.

Closes #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:55:33 +02:00
logaritmiskandClaude Opus 5 e4eb1cc6a2 docs(readme): document the stdin option
Refs #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:51:05 +02:00
logaritmiskandClaude Opus 5 38e9af1e3d test(stdin): pin keep-open through the daemon
Covers the parser -> supervisor -> spawn wiring the unit tests miss, and
characterizes the reported symptom: without `keep-open` the reader goes
Stopped, with it the server stays Running.

The harness now gives the daemon /dev/null on stdin. That is what
launchd does, and without it these tests prove nothing — the runner's
own stdin blocks on read, so an unfixed child looks identical to a
fixed one.

Refs #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:51:01 +02:00
logaritmiskandClaude Opus 5 b500882257 feat(supervisor): let a server keep its stdin open
Stdio-first MCP servers exit the moment they see EOF on fd 0. Under the
launchd agent the daemon's stdin is /dev/null, so a server that also
speaks HTTP still shuts down seconds after binding its port, and there
was no way to ask xy for anything else.

Adds a per-server `stdin` mode: `inherit` (the default, unchanged),
`null`, or `keep-open`. Under `keep-open` the child gets a pipe whose
write end RealChild holds and never writes to, so a read blocks.

The handle has to live on RealChild rather than on the TokioChild:
`Child::wait()` opens with `drop(self.stdin.take())`, so leaving it
where tokio put it reproduces the original bug the instant supervision
starts.

Refs #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:50:42 +02:00
logaritmiskandClaude Opus 5 7f605f361e test(uptime): assert on the wire value, not the rendered table
The test read uptime with split_whitespace().nth(4), which assumed the
column held a single token. That held only because the test measures
about a second of uptime; once the formatter emits two tokens above a
minute, raising the sleep would have shifted every later column while
the assertion kept passing.

Call the list RPC directly and assert on ServerSummary::uptime_secs.
The daemon produces that value and the test only reads it, so the
assertion no longer depends on a layout the project wrote itself.

Mutation-proved: replacing the handler's elapsed().as_secs() with 0
fails the test on uptime_secs: Some(0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9BWMXKKNpid7XM6oFbyYq
2026-08-10 14:42:57 +02:00
12 changed files with 448 additions and 390 deletions
Generated
+116 -367
View File
@@ -4,9 +4,9 @@ version = 4
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -63,19 +63,19 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
@@ -86,21 +86,21 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@@ -110,15 +110,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -126,9 +126,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.0" version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -138,14 +138,14 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.1" version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
@@ -197,9 +197,9 @@ dependencies = [
[[package]] [[package]]
name = "either" name = "either"
version = "1.17.0" version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]] [[package]]
name = "encoding-utils" name = "encoding-utils"
@@ -250,21 +250,15 @@ dependencies = [
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
@@ -279,24 +273,13 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
] ]
[[package]] [[package]]
@@ -322,9 +305,9 @@ dependencies = [
[[package]] [[package]]
name = "humantime" name = "humantime"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
[[package]] [[package]]
name = "humantime-serde" name = "humantime-serde"
@@ -336,22 +319,14 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -368,12 +343,13 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "kdl" name = "kdl"
version = "6.5.0" version = "6.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" checksum = "082ddf81b2acd76fe04412655d17befedfff1837db772f8e74c38050d25ed670"
dependencies = [ dependencies = [
"miette", "miette",
"num", "num-traits",
"serde",
"winnow", "winnow",
] ]
@@ -383,23 +359,17 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.18" version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@@ -418,9 +388,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.30" version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "matchers" name = "matchers"
@@ -433,9 +403,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]] [[package]]
name = "miette" name = "miette"
@@ -449,9 +419,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@@ -479,76 +449,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -578,9 +484,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.10.0" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
dependencies = [ dependencies = [
"base64", "base64",
"indexmap", "indexmap",
@@ -595,39 +501,29 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -651,9 +547,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -662,9 +558,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.10" version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "rustix" name = "rustix"
@@ -692,17 +588,11 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@@ -710,29 +600,29 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -778,15 +668,15 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -800,9 +690,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -816,7 +717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.2", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -833,11 +734,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl 2.0.18", "thiserror-impl 2.0.20",
] ]
[[package]] [[package]]
@@ -848,34 +749,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.18" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.54" version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [ dependencies = [
"deranged", "deranged",
"num-conv", "num-conv",
@@ -903,9 +804,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -919,20 +820,20 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.7.0" version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.18" version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"pin-project-lite", "pin-project-lite",
@@ -969,7 +870,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1023,12 +924,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -1047,58 +942,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "which" name = "which"
version = "4.4.2" version = "4.4.2"
@@ -1232,112 +1075,18 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.6.24" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.28" version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xy" name = "xy"
@@ -1368,7 +1117,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
"xy-protocol", "xy-protocol",
@@ -1384,7 +1133,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -1394,7 +1143,7 @@ dependencies = [
"async-trait", "async-trait",
"nix", "nix",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.20",
"tokio", "tokio",
"tokio-test", "tokio-test",
"tracing", "tracing",
@@ -1403,6 +1152,6 @@ dependencies = [
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+19
View File
@@ -47,6 +47,25 @@ Waiting does **not** consume the restart budget: a slow dependency costs
patience, not retries. This is what stops a Docker-backed server from being patience, not retries. This is what stops a Docker-backed server from being
marked failed at login while the Docker daemon is still starting. marked failed at login while the Docker daemon is still starting.
## Servers that read stdin
MCP servers are stdio-first by convention, and several that also speak HTTP
still start their stdio transport unconditionally. Under the launchd agent the
daemon's stdin is `/dev/null`, so such a server sees EOF on its first read and
shuts down seconds after binding its port.
`stdin` picks what the child gets on fd 0:
stdin "keep-open"
- `inherit` (default) — the child inherits the daemon's stdin.
- `null``/dev/null`; a read sees EOF immediately.
- `keep-open` — a pipe that `xy` holds open for the child's lifetime and never
writes to, so a read blocks instead of seeing EOF.
Use `keep-open` for a server you want supervised in HTTP mode that insists on
running its stdio transport anyway.
## Start on login (macOS) ## Start on login (macOS)
xy service install # write the LaunchAgent, load it, start the daemon xy service install # write the LaunchAgent, load it, start the daemon
+15
View File
@@ -66,6 +66,19 @@ impl Default for StopConfig {
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StdinMode {
/// The child inherits the daemon's stdin. Today's behaviour.
#[default]
Inherit,
/// The child gets `/dev/null`; a read sees EOF immediately.
Null,
/// The child gets a pipe that `xy` holds open and never writes to,
/// so a read blocks instead of seeing EOF.
KeepOpen,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerConfig { pub struct ServerConfig {
pub name: String, pub name: String,
@@ -83,6 +96,8 @@ pub struct ServerConfig {
pub stop: StopConfig, pub stop: StopConfig,
#[serde(default)] #[serde(default)]
pub wait_for: Option<WaitForConfig>, pub wait_for: Option<WaitForConfig>,
#[serde(default)]
pub stdin: StdinMode,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+78 -1
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig, WaitCondition, ConfigError, RestartConfig, RestartPolicy, ServerConfig, StdinMode, StopConfig, WaitCondition,
WaitForConfig, default_wait_interval, default_wait_timeout, WaitForConfig, default_wait_interval, default_wait_timeout,
}; };
use kdl::{KdlDocument, KdlNode}; use kdl::{KdlDocument, KdlNode};
@@ -31,6 +31,7 @@ pub fn parse_server_config(
let restart = parse_restart(&doc, source_path)?; let restart = parse_restart(&doc, source_path)?;
let stop = parse_stop(&doc, source_path)?; let stop = parse_stop(&doc, source_path)?;
let wait_for = parse_wait_for(&doc, source_path)?; let wait_for = parse_wait_for(&doc, source_path)?;
let stdin = parse_stdin(&doc, source_path)?;
Ok(ServerConfig { Ok(ServerConfig {
name: name.to_string(), name: name.to_string(),
@@ -42,6 +43,7 @@ pub fn parse_server_config(
restart, restart,
stop, stop,
wait_for, wait_for,
stdin,
}) })
} }
@@ -214,6 +216,27 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
Ok(out) Ok(out)
} }
fn parse_stdin(doc: &KdlDocument, path: &Path) -> Result<StdinMode, ConfigError> {
let Some(node) = find_node(doc, "stdin") else {
return Ok(StdinMode::default());
};
single_arg(node, "stdin", path)?;
let s = string_arg(node, "stdin", path)?;
match s.as_str() {
"inherit" => Ok(StdinMode::Inherit),
"null" => Ok(StdinMode::Null),
"keep-open" => Ok(StdinMode::KeepOpen),
other => Err(ConfigError::InvalidValue {
path: path.to_path_buf(),
field: "stdin",
message: format!("unknown stdin mode `{other}`"),
}),
}
}
fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError> { fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError> {
let Some(node) = find_node(doc, "stop") else { let Some(node) = find_node(doc, "stop") else {
return Ok(StopConfig::default()); return Ok(StopConfig::default());
@@ -810,4 +833,58 @@ stop {
WaitCondition::Path(PathBuf::from("/x")) WaitCondition::Path(PathBuf::from("/x"))
); );
} }
#[test]
fn stdin_defaults_to_inherit() {
let text = "command \"/bin/x\"\nport 1";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::Inherit);
}
#[test]
fn parses_stdin_inherit() {
let text = "command \"/bin/x\"\nport 1\nstdin \"inherit\"";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::Inherit);
}
#[test]
fn parses_stdin_null() {
let text = "command \"/bin/x\"\nport 1\nstdin \"null\"";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::Null);
}
#[test]
fn parses_stdin_keep_open() {
let text = "command \"/bin/x\"\nport 1\nstdin \"keep-open\"";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::KeepOpen);
}
#[test]
fn unknown_stdin_mode_fails() {
let text = "command \"/bin/x\"\nport 1\nstdin \"maybe\"";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue { field: "stdin", .. }
));
}
#[test]
fn stdin_with_two_args_fails() {
let text = "command \"/bin/x\"\nport 1\nstdin \"null\" \"keep-open\"";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue { field: "stdin", .. }
));
}
} }
+2 -2
View File
@@ -7,8 +7,8 @@ pub mod rpc;
pub mod state; pub mod state;
pub use config::{ pub use config::{
RestartConfig, RestartPolicy, ServerConfig, StopConfig, WaitCondition, WaitForConfig, RestartConfig, RestartPolicy, ServerConfig, StdinMode, StopConfig, WaitCondition,
default_wait_interval, default_wait_timeout, WaitForConfig, default_wait_interval, default_wait_timeout,
}; };
pub use error::{ConfigError, RpcErrorCode}; pub use error::{ConfigError, RpcErrorCode};
pub use kdl_parse::{load_all_configs, parse_server_config}; pub use kdl_parse::{load_all_configs, parse_server_config};
+81 -2
View File
@@ -76,13 +76,18 @@ use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid; use nix::unistd::Pid;
use std::process::Stdio; use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child as TokioChild, Command}; use tokio::process::{Child as TokioChild, ChildStdin, Command};
use xy_protocol::{ServerConfig, rpc::LogStream}; use xy_protocol::{ServerConfig, StdinMode, rpc::LogStream};
pub struct RealChild { pub struct RealChild {
pid: u32, pid: u32,
pgid: Pid, pgid: Pid,
child: Option<TokioChild>, child: Option<TokioChild>,
/// Write end of the child's stdin pipe under [`StdinMode::KeepOpen`].
/// Never written to; held so the child blocks on read rather than
/// seeing EOF. `TokioChild::wait()` drops the handle it owns, so the
/// pipe must live out here to outlast supervision.
_stdin: Option<ChildStdin>,
} }
impl RealChild { impl RealChild {
@@ -132,6 +137,16 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
cmd.current_dir(dir); cmd.current_dir(dir);
} }
match cfg.stdin {
StdinMode::Inherit => {}
StdinMode::Null => {
cmd.stdin(Stdio::null());
}
StdinMode::KeepOpen => {
cmd.stdin(Stdio::piped());
}
}
cmd.stdout(Stdio::piped()); cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true); cmd.kill_on_drop(true);
@@ -149,6 +164,8 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
let pid = child.id().ok_or_else(|| std::io::Error::other("no pid"))?; let pid = child.id().ok_or_else(|| std::io::Error::other("no pid"))?;
let pgid = Pid::from_raw(pid as i32); let pgid = Pid::from_raw(pid as i32);
let stdin = child.stdin.take();
if let Some(out) = child.stdout.take() { if let Some(out) = child.stdout.take() {
spawn_pump(out, sink.clone(), LogStream::Stdout); spawn_pump(out, sink.clone(), LogStream::Stdout);
} }
@@ -161,6 +178,7 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
pid, pid,
pgid, pgid,
child: Some(child), child: Some(child),
_stdin: stdin,
}) })
} }
@@ -208,4 +226,65 @@ mod tests {
child.terminate().unwrap(); child.terminate().unwrap();
ctl.terminate_rx.try_recv().unwrap(); ctl.terminate_rx.try_recv().unwrap();
} }
use crate::logs::RotatingLogWriter;
use std::time::Duration;
use tempfile::tempdir;
use xy_protocol::{RestartConfig, StdinMode, StopConfig};
fn reader_cfg(stdin: StdinMode, script: &str) -> ServerConfig {
ServerConfig {
name: "reader".to_string(),
command: "/bin/sh".into(),
args: vec!["-c".into(), script.to_string()],
port: 1,
env: Default::default(),
working_dir: None,
restart: RestartConfig::default(),
stop: StopConfig::default(),
wait_for: None,
stdin,
}
}
fn test_sink() -> LogSink {
let dir = tempdir().unwrap();
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
std::mem::forget(dir);
LogSink::new("reader".to_string(), writer, 1024)
}
/// Exits 9 straight away unless fd 0 is a FIFO, so the assertion below
/// cannot be satisfied by whatever stdin the test runner happened to have.
const REQUIRE_PIPE_THEN_READ: &str = "[ -p /dev/stdin ] || exit 9; read line; exit 7";
#[tokio::test]
async fn keep_open_gives_the_child_a_pipe_that_survives_wait() {
let cfg = reader_cfg(StdinMode::KeepOpen, REQUIRE_PIPE_THEN_READ);
let mut child = spawn_with_logs(&cfg, test_sink()).unwrap();
// `TokioChild::wait()` drops its own stdin handle on entry, so this
// blocks only if `RealChild` took the handle and is still holding it.
let outcome = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
assert!(
outcome.is_err(),
"child should still be blocked on read, got {outcome:?}"
);
child.kill().unwrap();
}
#[tokio::test]
async fn null_stdin_child_sees_eof_immediately() {
let cfg = reader_cfg(StdinMode::Null, "read line; exit 7");
let mut child = spawn_with_logs(&cfg, test_sink()).unwrap();
let code = tokio::time::timeout(Duration::from_millis(2000), child.wait())
.await
.expect("child should exit promptly on EOF")
.unwrap();
assert_eq!(code, Some(7));
}
} }
+1
View File
@@ -536,6 +536,7 @@ mod tests {
grace: Duration::from_millis(50), grace: Duration::from_millis(50),
}, },
wait_for: None, wait_for: None,
stdin: Default::default(),
} }
} }
+4
View File
@@ -16,6 +16,10 @@ path = "src/bin/xy_test_sleep_server.rs"
name = "xy-test-exit-failure" name = "xy-test-exit-failure"
path = "src/bin/xy_test_exit_failure.rs" path = "src/bin/xy_test_exit_failure.rs"
[[bin]]
name = "xy-test-stdin-reader"
path = "src/bin/xy_test_stdin_reader.rs"
[dependencies] [dependencies]
xy-protocol.workspace = true xy-protocol.workspace = true
xy-supervisor.workspace = true xy-supervisor.workspace = true
+21
View File
@@ -0,0 +1,21 @@
/// Stands in for a stdio-first MCP server: it comes up, announces itself, then
/// blocks reading stdin and exits the moment it sees EOF.
fn main() {
use std::io::{BufRead, Write};
println!("ready");
std::io::stdout().flush().ok();
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) => {
eprintln!("stdin closed (EOF), shutting down");
std::process::exit(0);
}
Ok(_) => std::process::exit(1),
Err(err) => {
eprintln!("stdin read error: {err}");
std::process::exit(2);
}
}
}
+29 -1
View File
@@ -5,6 +5,8 @@ use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use tempfile::TempDir; use tempfile::TempDir;
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use xy_ipc::Client;
use xy_protocol::rpc::{ServerSummary, methods};
pub struct Harness { pub struct Harness {
pub tmp: TempDir, pub tmp: TempDir,
@@ -35,8 +37,20 @@ impl Harness {
} }
pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) { pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) {
self.write_server_with(name, command, port, restart_policy, "");
}
/// `extra` is appended verbatim as additional top-level KDL.
pub fn write_server_with(
&self,
name: &str,
command: &str,
port: u16,
restart_policy: &str,
extra: &str,
) {
let body = format!( let body = format!(
"command \"{command}\"\nport {port}\nrestart {{\n policy \"{restart_policy}\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\nstop {{ grace \"500ms\" }}\n" "command \"{command}\"\nport {port}\nrestart {{\n policy \"{restart_policy}\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\nstop {{ grace \"500ms\" }}\n{extra}"
); );
std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap(); std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap();
} }
@@ -47,6 +61,7 @@ impl Harness {
.env("XDG_CONFIG_HOME", self.tmp.path().join("config")) .env("XDG_CONFIG_HOME", self.tmp.path().join("config"))
.env("XDG_STATE_HOME", self.tmp.path().join("state")) .env("XDG_STATE_HOME", self.tmp.path().join("state"))
.env("XDG_RUNTIME_DIR", self.tmp.path().join("run")) .env("XDG_RUNTIME_DIR", self.tmp.path().join("run"))
.stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.kill_on_drop(true) .kill_on_drop(true)
@@ -62,6 +77,16 @@ impl Harness {
} }
} }
/// The `list` RPC result straight off the wire, before any CLI rendering.
pub async fn list_rpc(&self) -> Vec<ServerSummary> {
let mut client = Client::connect(&self.socket).await.expect("connect daemon");
client
.call_no_params(methods::LIST)
.await
.expect("list rpc")
}
pub async fn run_cli(&self, xy_bin: &PathBuf, args: &[&str]) -> (i32, String, String) { pub async fn run_cli(&self, xy_bin: &PathBuf, args: &[&str]) -> (i32, String, String) {
let out = Command::new(xy_bin) let out = Command::new(xy_bin)
.args(args) .args(args)
@@ -87,6 +112,9 @@ pub fn sleep_server_bin() -> PathBuf {
pub fn exit_failure_bin() -> PathBuf { pub fn exit_failure_bin() -> PathBuf {
artifact("xy-test-exit-failure") artifact("xy-test-exit-failure")
} }
pub fn stdin_reader_bin() -> PathBuf {
artifact("xy-test-stdin-reader")
}
fn artifact(name: &str) -> PathBuf { fn artifact(name: &str) -> PathBuf {
let mut p = std::env::current_exe().unwrap(); let mut p = std::env::current_exe().unwrap();
+68
View File
@@ -0,0 +1,68 @@
mod common;
use common::*;
use std::time::Duration;
use xy_protocol::ServerState;
async fn state_of(h: &Harness, name: &str) -> ServerState {
h.list_rpc()
.await
.into_iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("no server named {name}"))
.state
}
async fn settle(h: &Harness, name: &str) -> ServerState {
let mut last = ServerState::Stopped;
for _ in 0..40 {
last = state_of(h, name).await;
if last == ServerState::Running {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
last
}
#[tokio::test]
async fn keep_open_lets_a_stdin_reader_stay_running() {
let xy = xy_bin();
let reader = stdin_reader_bin();
let mut h = Harness::new();
h.write_server_with(
"reader",
reader.to_str().unwrap(),
19_010,
"never",
"stdin \"keep-open\"\n",
);
h.start_daemon(&xy).await;
assert_eq!(settle(&h, "reader").await, ServerState::Running);
// Still up a beat later: it is blocked on read, not merely slow to die.
tokio::time::sleep(Duration::from_millis(500)).await;
assert_eq!(state_of(&h, "reader").await, ServerState::Running);
}
/// Characterizes the reported bug: the daemon's stdin is `/dev/null` under
/// launchd, so by default a stdio-first server sees EOF and exits at once.
#[tokio::test]
async fn without_keep_open_a_stdin_reader_exits_on_eof() {
let xy = xy_bin();
let reader = stdin_reader_bin();
let mut h = Harness::new();
h.write_server("reader", reader.to_str().unwrap(), 19_011, "never");
h.start_daemon(&xy).await;
let mut last = ServerState::Running;
for _ in 0..40 {
last = state_of(&h, "reader").await;
if last == ServerState::Stopped {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(last, ServerState::Stopped);
}
+14 -17
View File
@@ -1,5 +1,6 @@
mod common; mod common;
use common::*; use common::*;
use xy_protocol::ServerState;
#[tokio::test] #[tokio::test]
async fn uptime_grows_while_a_server_stays_running() { async fn uptime_grows_while_a_server_stays_running() {
@@ -10,8 +11,11 @@ async fn uptime_grows_while_a_server_stays_running() {
h.start_daemon(&xy).await; h.start_daemon(&xy).await;
for _ in 0..40 { for _ in 0..40 {
let (_c, out, _e) = h.run_cli(&xy, &["list"]).await; let rows = h.list_rpc().await;
if out.contains("alpha") && out.contains("running") { if rows
.iter()
.any(|r| r.name == "alpha" && r.state == ServerState::Running)
{
break; break;
} }
tokio::time::sleep(std::time::Duration::from_millis(100)).await; tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -19,22 +23,15 @@ async fn uptime_grows_while_a_server_stays_running() {
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
let (code, out, _e) = h.run_cli(&xy, &["list"]).await; let rows = h.list_rpc().await;
assert_eq!(code, 0); let alpha = rows
.iter()
.find(|r| r.name == "alpha")
.unwrap_or_else(|| panic!("no alpha row in: {rows:?}"));
let row = out assert!(
.lines() alpha.uptime_secs.is_some_and(|secs| secs >= 1),
.find(|l| l.starts_with("alpha")) "a server running for over a second must report at least 1s of uptime; row: {alpha:?}"
.unwrap_or_else(|| panic!("no alpha row in:\n{out}"));
let uptime = row
.split_whitespace()
.nth(4)
.unwrap_or_else(|| panic!("no uptime column in row: {row:?}"));
assert_ne!(
uptime, "0s",
"a server running for over a second must not report 0s uptime; row: {row:?}"
); );
} }